davx5/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/DavResourceFinder.kt
2025-11-20 14:05:57 +01:00

506 lines
20 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.servicedetection
import android.app.ActivityManager
import android.content.Context
import androidx.core.content.getSystemService
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.caldav.CalendarColor
import at.bitfire.dav4jvm.property.caldav.CalendarDescription
import at.bitfire.dav4jvm.property.caldav.CalendarHomeSet
import at.bitfire.dav4jvm.property.caldav.CalendarTimezone
import at.bitfire.dav4jvm.property.caldav.CalendarUserAddressSet
import at.bitfire.dav4jvm.property.caldav.SupportedCalendarComponentSet
import at.bitfire.dav4jvm.property.carddav.AddressbookDescription
import at.bitfire.dav4jvm.property.carddav.AddressbookHomeSet
import at.bitfire.dav4jvm.property.common.HrefListProperty
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrincipal
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
import at.bitfire.dav4jvm.property.webdav.DisplayName
import at.bitfire.dav4jvm.property.webdav.ResourceType
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.DnsRecordResolver
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Credentials
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.xbill.DNS.Type
import java.io.InterruptedIOException
import java.net.SocketTimeoutException
import java.net.URI
import java.net.URISyntaxException
import java.util.LinkedList
import java.util.logging.Level
import java.util.logging.Logger
/**
* Does initial resource detection when an account is added. It uses the (user given) base URL to find
*
* - services (CalDAV and/or CardDAV),
* - principal,
* - homeset/collections (multistatus responses are handled through dav4jvm).
*
* @param context to build the HTTP client
* @param baseURI user-given base URI (either mailto: URI or http(s):// URL)
* @param credentials optional login credentials (username/password, client certificate, OAuth state)
*/
class DavResourceFinder @AssistedInject constructor(
@Assisted private val baseURI: URI,
@Assisted private val credentials: Credentials? = null,
@ApplicationContext val context: Context,
private val dnsRecordResolver: DnsRecordResolver,
httpClientBuilder: HttpClient.Builder
): AutoCloseable {
@AssistedFactory
interface Factory {
fun create(baseURI: URI, credentials: Credentials?): DavResourceFinder
}
enum class Service(val wellKnownName: String) {
CALDAV("caldav"),
CARDDAV("carddav");
override fun toString() = wellKnownName
}
val log: Logger = Logger.getLogger(javaClass.name)
private val logBuffer: StringHandler = initLogging()
private var encountered401 = false
private val httpClient = httpClientBuilder
.setLogger(log)
.apply {
if (credentials != null)
authenticate(
host = null,
getCredentials = { credentials }
)
}
.build()
override fun close() {
httpClient.close()
}
private fun initLogging(): StringHandler {
// don't use more than 1/4 of the available memory for a log string
val activityManager = context.getSystemService<ActivityManager>()!!
val maxLogSize = activityManager.memoryClass * (1024 * 1024 / 8)
val handler = StringHandler(maxLogSize)
// add StringHandler to logger
log.level = Level.ALL
log.addHandler(handler)
return handler
}
/**
* Finds the initial configuration (= runs the service detection process).
*
* In case of an error, it returns an empty [Configuration] with error logs
* instead of throwing an [Exception].
*
* @return service information if there's neither a CalDAV service nor a CardDAV service,
* service detection was not successful
*/
fun findInitialConfiguration(): Configuration {
var cardDavConfig: Configuration.ServiceInfo? = null
var calDavConfig: Configuration.ServiceInfo? = null
try {
try {
cardDavConfig = findInitialConfiguration(Service.CARDDAV)
} catch (e: Exception) {
log.log(Level.INFO, "CardDAV service detection failed", e)
processException(e)
}
try {
calDavConfig = findInitialConfiguration(Service.CALDAV)
} catch (e: Exception) {
log.log(Level.INFO, "CalDAV service detection failed", e)
processException(e)
}
} catch(_: Exception) {
// we have been interrupted; reset results so that an error message will be shown
cardDavConfig = null
calDavConfig = null
}
return Configuration(
cardDAV = cardDavConfig,
calDAV = calDavConfig,
encountered401 = encountered401,
logs = logBuffer.toString()
)
}
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
// domain for service discovery
var discoveryFQDN: String? = null
// discovered information goes into this config
val config = Configuration.ServiceInfo()
// Start discovering
log.info("Finding initial ${service.wellKnownName} service configuration")
when (baseURI.scheme.lowercase()) {
"http", "https" ->
baseURI.toHttpUrlOrNull()?.let { baseURL ->
// remember domain for service discovery
if (baseURL.scheme.equals("https", true))
// service discovery will only be tried for https URLs, because only secure service discovery is implemented
discoveryFQDN = baseURL.host
// Actual discovery process
checkBaseURL(baseURL, service, config)
// If principal was not found already, try well known URI
if (config.principal == null)
try {
config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service)
} catch(e: Exception) {
log.log(Level.FINE, "Well-known URL detection failed", e)
processException(e)
}
}
"mailto" -> {
val mailbox = baseURI.schemeSpecificPart
val posAt = mailbox.lastIndexOf("@")
if (posAt != -1)
discoveryFQDN = mailbox.substring(posAt + 1)
}
}
// Second try: If user-given URL didn't reveal a principal, search for it (SERVICE DISCOVERY)
if (config.principal == null)
discoveryFQDN?.let { fqdn ->
log.info("No principal found at user-given URL, trying to discover for domain $fqdn")
try {
config.principal = discoverPrincipalUrl(fqdn, service)
} catch(e: Exception) {
log.log(Level.FINE, "$service service discovery failed", e)
processException(e)
}
}
// detect email address
if (service == Service.CALDAV)
config.principal?.let { principal ->
config.emails.addAll(queryEmailAddress(principal))
}
// return config or null if config doesn't contain useful information
val serviceAvailable = config.principal != null || config.homeSets.isNotEmpty() || config.collections.isNotEmpty()
return if (serviceAvailable)
config
else
null
}
/**
* Entry point of the actual discovery process.
*
* Queries the user-given URL (= base URL) to detect whether it contains a current-user-principal
* or whether it is a homeset or collection.
*
* @param baseURL base URL provided by the user
* @param service service to detect configuration for
* @param config found configuration will be written to this object
*/
private fun checkBaseURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) {
log.info("Checking user-given URL: $baseURL")
val davBaseURL = DavResource(httpClient.okHttpClient, baseURL, log)
try {
when (service) {
Service.CARDDAV -> {
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
AddressbookHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(ResourceType.ADDRESSBOOK, response, config)
}
}
Service.CALDAV -> {
davBaseURL.propfind(
0,
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
CalendarHomeSet.NAME,
CurrentUserPrincipal.NAME
) { response, _ ->
scanResponse(ResourceType.CALENDAR, response, config)
}
}
}
} catch(e: Exception) {
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
processException(e)
}
}
/**
* Queries a user's email address using CalDAV scheduling: calendar-user-address-set.
* @param principal principal URL of the user
* @return list of found email addresses (empty if none)
*/
fun queryEmailAddress(principal: HttpUrl): List<String> {
val mailboxes = LinkedList<String>()
try {
DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ ->
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
for (href in addressSet.hrefs)
try {
val uri = URI(href)
if (uri.scheme.equals("mailto", true))
mailboxes.add(uri.schemeSpecificPart)
} catch(e: URISyntaxException) {
log.log(Level.WARNING, "Couldn't parse user address", e)
}
}
}
} catch(e: Exception) {
log.log(Level.WARNING, "Couldn't query user email address", e)
processException(e)
}
return mailboxes
}
/**
* Depending on [resourceType] (CalDAV or CardDAV), this method checks whether [davResponse] references
* - an address book or calendar (actual resource), and/or
* - an "address book home set" or a "calendar home set", and/or
* - whether it's a principal.
*
* Respectively, this method will add the response to [config.collections], [config.homesets] and/or [config.principal].
* Collection URLs will be stored with trailing "/".
*
* @param resourceType type of service to search for in the response
* @param davResponse response whose properties are evaluated
* @param config structure storing the references
*/
fun scanResponse(resourceType: Property.Name, davResponse: Response, config: Configuration.ServiceInfo) {
var principal: HttpUrl? = null
// Type mapping
val homeSetClass: Class<out HrefListProperty>
val serviceType: Service
when (resourceType) {
ResourceType.ADDRESSBOOK -> {
homeSetClass = AddressbookHomeSet::class.java
serviceType = Service.CARDDAV
}
ResourceType.CALENDAR -> {
homeSetClass = CalendarHomeSet::class.java
serviceType = Service.CALDAV
}
else -> throw IllegalArgumentException()
}
// check for current-user-principal
davResponse[CurrentUserPrincipal::class.java]?.href?.let { currentUserPrincipal ->
principal = davResponse.requestedUrl.resolve(currentUserPrincipal)
}
davResponse[ResourceType::class.java]?.let {
// Is it a calendar or an address book, ...
if (it.types.contains(resourceType))
Collection.fromDavResponse(davResponse)?.let { info ->
log.info("Found resource of type $resourceType at ${info.url}")
config.collections[info.url] = info
}
// ... and/or a principal?
if (it.types.contains(ResourceType.PRINCIPAL))
principal = davResponse.href
}
// Is it an addressbook-home-set or calendar-home-set?
davResponse[homeSetClass]?.let { homeSet ->
for (href in homeSet.hrefs) {
davResponse.requestedUrl.resolve(href)?.let {
val location = UrlUtils.withTrailingSlash(it)
log.info("Found home-set of type $resourceType at $location")
config.homeSets += location
}
}
}
// Is there a principal too?
principal?.let {
if (providesService(it, serviceType))
config.principal = principal
else
log.warning("Principal $principal doesn't provide $serviceType service")
}
}
/**
* Sends an OPTIONS request to determine whether a URL provides a given service.
*
* @param url URL to check; often a principal URL
* @param service service to check for
*
* @return whether the URL provides the given service
*/
fun providesService(url: HttpUrl, service: Service): Boolean {
var provided = false
try {
DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ ->
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
provided = true
}
} catch(e: Exception) {
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
if (e !is HttpException && e !is DavException)
throw e
}
return provided
}
/**
* Try to find the principal URL by performing service discovery on a given domain name.
* Only secure services (caldavs, carddavs) will be discovered!
*
* @param domain domain name, e.g. "icloud.com"
* @param service service to discover (CALDAV or CARDDAV)
* @return principal URL, or null if none found
*/
fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
val scheme: String
val fqdn: String
var port = 443
val paths = LinkedList<String>() // there may be multiple paths to try
val query = "_${service.wellKnownName}s._tcp.$domain"
log.fine("Looking up SRV records for $query")
val srvRecords = dnsRecordResolver.resolve(query, Type.SRV)
val srv = dnsRecordResolver.bestSRVRecord(srvRecords)
if (srv != null) {
// choose SRV record to use (query may return multiple SRV records)
scheme = "https"
fqdn = srv.target.toString(true)
port = srv.port
log.info("Found $service service at https://$fqdn:$port")
} else {
// no SRV records, try domain name as FQDN
log.info("Didn't find $service service, trying at https://$domain:$port")
scheme = "https"
fqdn = domain
}
// look for TXT record too (for initial context path)
val txtRecords = dnsRecordResolver.resolve(query, Type.TXT)
paths.addAll(dnsRecordResolver.pathsFromTXTRecords(txtRecords))
// in case there's a TXT record, but it's wrong, try well-known
paths.add("/.well-known/" + service.wellKnownName)
// if this fails too, try "/"
paths.add("/")
for (path in paths)
try {
val initialContextPath = HttpUrl.Builder()
.scheme(scheme)
.host(fqdn).port(port)
.encodedPath(path)
.build()
log.info("Trying to determine principal from initial context path=$initialContextPath")
val principal = getCurrentUserPrincipal(initialContextPath, service)
principal?.let { return it }
} catch(e: Exception) {
log.log(Level.WARNING, "No resource found", e)
processException(e)
}
return null
}
/**
* Queries a given URL for current-user-principal
*
* @param url URL to query with PROPFIND (Depth: 0)
* @param service required service (may be null, in which case no service check is done)
* @return current-user-principal URL that provides required service, or null if none
*/
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
var principal: HttpUrl? = null
DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
response[CurrentUserPrincipal::class.java]?.href?.let { href ->
response.requestedUrl.resolve(href)?.let {
log.info("Found current-user-principal: $it")
// service check
if (service != null && !providesService(it, service))
log.warning("Principal $it doesn't provide $service service")
else
principal = it
}
}
}
return principal
}
/**
* Processes a thrown exception like this:
*
* - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*.
* - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation.
*/
private fun processException(e: Exception) {
if (e is UnauthorizedException)
encountered401 = true
else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException)
throw e
}
// data classes
class Configuration(
val cardDAV: ServiceInfo?,
val calDAV: ServiceInfo?,
val encountered401: Boolean,
val logs: String
) {
data class ServiceInfo(
var principal: HttpUrl? = null,
val homeSets: MutableSet<HttpUrl> = HashSet(),
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
val emails: MutableList<String> = LinkedList()
)
override fun toString() =
"DavResourceFinder.Configuration(cardDAV=$cardDAV, calDAV=$calDAV, encountered401=$encountered401, logs=(${logs.length} chars))"
}
}