Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Awkward API to get token and value for http challenge #8

Open
sliemeobn opened this issue Jan 25, 2024 · 1 comment
Open

Awkward API to get token and value for http challenge #8

sliemeobn opened this issue Jan 25, 2024 · 1 comment

Comments

@sliemeobn
Copy link

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.

@dimitribouniol
Copy link
Contributor

dimitribouniol commented Jan 27, 2024

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants