Awkward API to get token and value for http challenge
tl;dr: Should we add the token to the ChallengeDescription type?
First: Thank you for this package! When I stumbled over the need to self-manage public certificates I couldn't believe my luck: There is a swift package for ACME!
A tiny little thing I think could be improved:
Currently, you can either call acme.orders.getAuthorizations(from: order) to get the "raw data", or use the acme.orders.describePendingChallenges(from: order, preferring: .http) API to get the little value encoding dance for free.
Getting the correct value for the challenges is clearly nicer, but on ChallengeDescription you can not access the token directly.
Based on how you'd implement "placing" the http value on you server, you'll now have to extract it out of the endpoint URL again.
Additionally, we could also move the get me the value for this challenge code to some reusable place to decouple it from the describePendingChallenges API.
For what it's worth, I think you have everything you need in ChallengeDescription. Here is what I use in my deployment:
struct ChallengeKey: Hashable {
var host: String
var path: String
}
extension ChallengeKey {
init(url urlString: String) throws {
guard
let url = URL(string: urlString),
let host = url.host()
else { throw CertificateRenewalError.invalidURL(urlString) }
self.host = host
self.path = url.path(percentEncoded: true)
}
}
actor CertificateManager {
// ...
var activeChallenges: [ChallengeKey : ChallengeDescription] = [:]
func refresh() async throws -> Date {
// ...
let pendingChallenges = try await acmeClient.orders.describePendingChallenges(from: certificateOrder, preferring: .http)
defer { activeChallenges = [:] }
activeChallenges = [:]
logger.notice("Awaiting verifications for order:")
for challenge in pendingChallenges {
guard case .http = challenge.type else {
logger.warning("Unknown type encountered: \(challenge.type)")
continue
}
logger.notice(" • The URL \(challenge.endpoint) needs to return \(challenge.value)")
activeChallenges[try ChallengeKey(url: challenge.endpoint)] = challenge
}
var failedChallenges = try await acmeClient.orders.validateChallenges(from: certificateOrder, preferring: .http)
for timeout in [5, 10, 10, 10, 30] {
guard !failedChallenges.isEmpty else { break }
logger.notice("\(failedChallenges.count) challenges remain, trying again in \(timeout) seconds.")
try await Task.sleep(for: .seconds(timeout))
failedChallenges = try await acmeClient.orders.validateChallenges(from: certificateOrder, preferring: .http)
}
guard failedChallenges.isEmpty else {
logger.error("Certificate order validations failed: \(failedChallenges)")
throw CertificateRenewalError.failedToValidate(failedChallenges)
}
logger.notice("Finished validating orders. Downloading certificates.")
let (privateKey, _, finalizedOrders) = try await acmeClient.orders.finalizeWithEcdsa(order: certificateOrder, domains: domains)
// ...
}
}
actor HTTPApplication {
// ...
@Sendable
func redirect(request: Request) async throws -> Response {
guard let host = request.headers[.host].first, validDomains.contains(host)
else { return request.redirect(to: "https://\(request.application.environment.certificateDomains[0])\(request.url)", redirectType: .temporary) }
// If we have active challenges to fulfill, go ahead and check if any requests match, and return the challenge value
if let activeChallenges = await certificateManager?.activeChallenges, !activeChallenges.isEmpty {
let key = ChallengeKey(host: host, path: request.url.path)
request.logger.notice("Checking challenge for \(key) in \(activeChallenges.keys)")
if let challenge = activeChallenges[key] {
request.logger.notice("Returning for \(key): \(challenge.value)")
return Response(status: .ok, body: .init(string: challenge.value))
}
}
// Otherwise redirect to HTTPS
return request.redirect(to: "https://\(host)\(request.url)", redirectType: .temporary)
}
}
(Edit: I guess we can avoid the URL parsing, though this doesn't need to be done in a hot path, so the above works fine for me)