Issue real certificates via ACME DNS-01
Tunnels now get a per-tunnel certificate from the configured ACME CA (Let's Encrypt by default) with DNS-01 through Porkbun, keeping the client's CSR and key. Issuance is asynchronous (issuing/ready/failed); the account persists in the store, renewals run in a 30-day window with retries, and a failed renewal keeps the old certificate serving. Switching from the local CA reissues existing tunnels automatically, and the admin dashboard shows the issuer and certificate state. Verified against Pebble and Let's Encrypt staging.
This commit is contained in:
+154
-31
@@ -1,12 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,6 +23,12 @@ type API struct {
|
||||
AdminToken string // bearer token for /api/admin/*; empty disables it
|
||||
StartedAt time.Time // for the uptime shown on the dashboard
|
||||
Version string
|
||||
|
||||
Issuer Issuer // certificate issuer; defaults to the local CA
|
||||
IssuerName string // "local" or the ACME directory URL
|
||||
|
||||
issueMu sync.Mutex
|
||||
issuing map[string]bool
|
||||
}
|
||||
|
||||
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -167,21 +176,30 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Binding the CSR the tunnel already has answers with its current
|
||||
// certificate; a different one re-issues (with the local CA, at once).
|
||||
if tunnel.Certificate != nil && tunnel.Certificate.CSR == csr {
|
||||
a.writeCertificate(w, http.StatusAccepted, tunnel.Certificate)
|
||||
return
|
||||
// Binding the CSR the tunnel already has answers with its current state;
|
||||
// a different CSR re-issues. A failed issuance for the same CSR retries.
|
||||
cert := tunnel.Certificate
|
||||
if cert != nil && cert.CSR == csr {
|
||||
if certReady(cert) {
|
||||
a.writeCertificate(w, http.StatusAccepted, cert)
|
||||
return
|
||||
}
|
||||
if cert.State != "failed" {
|
||||
// Same CSR, issuance incomplete: resume it (for example after a
|
||||
// restart) and answer with its current state.
|
||||
a.startIssue(tunnel.ID, tunnel.Hostname, csr, cert.ID)
|
||||
a.writeCertificate(w, http.StatusAccepted, cert)
|
||||
return
|
||||
}
|
||||
}
|
||||
ca, err := a.Store.ca()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, errInvalidRequest{
|
||||
Tag: "ServiceUnavailableError", Message: "storage unavailable",
|
||||
if (cert != nil && cert.State == "issuing") || a.isIssuing(id) {
|
||||
writeJSON(w, http.StatusConflict, errTaggedID{
|
||||
Tag: "CertificateInProgressError", TunnelID: id,
|
||||
Message: "Certificate issuance is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
certPEM, chainPEM, expiry, err := ca.signCSR(csr, tunnel.Hostname)
|
||||
if err != nil {
|
||||
if _, err := parseCSR(csr, tunnel.Hostname); err != nil {
|
||||
var hostErr errCSRHostname
|
||||
if errors.As(err, &hostErr) {
|
||||
writeJSON(w, http.StatusBadRequest, errInvalidHostname{
|
||||
@@ -197,20 +215,13 @@ func (a *API) bindCertificate(w http.ResponseWriter, r *http.Request, id string)
|
||||
})
|
||||
return
|
||||
}
|
||||
cert := storedCert{
|
||||
ID: certID(),
|
||||
CSR: csr,
|
||||
Certificate: string(certPEM),
|
||||
Chain: string(chainPEM),
|
||||
Expiry: expiryString(expiry),
|
||||
}
|
||||
if err := a.Store.bindCertificate(id, cert); err != nil {
|
||||
fresh := storedCert{ID: certID(), CSR: csr, State: "issuing", Issuer: a.issuerName()}
|
||||
if err := a.Store.bindCertificate(id, fresh); err != nil {
|
||||
notFoundEmpty(w)
|
||||
return
|
||||
}
|
||||
// The contract's bind response is 202 with state "issuing"; with the local
|
||||
// CA the very next GET already answers "ready".
|
||||
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: cert.ID, State: CertState{Type: "issuing"}})
|
||||
a.startIssue(tunnel.ID, tunnel.Hostname, csr, fresh.ID)
|
||||
writeJSON(w, http.StatusAccepted, CertificateInfo{ID: fresh.ID, State: CertState{Type: "issuing"}})
|
||||
}
|
||||
|
||||
// ---- GET /api/tunnel/{id}/certificate
|
||||
@@ -230,15 +241,127 @@ func (a *API) getCertificate(w http.ResponseWriter, r *http.Request, id string)
|
||||
}
|
||||
|
||||
func (a *API) writeCertificate(w http.ResponseWriter, status int, cert *storedCert) {
|
||||
writeJSON(w, status, CertificateInfo{
|
||||
ID: cert.ID,
|
||||
State: CertState{
|
||||
Type: "ready",
|
||||
Certificate: cert.Certificate,
|
||||
Chain: cert.Chain,
|
||||
Expiry: cert.Expiry,
|
||||
},
|
||||
})
|
||||
writeJSON(w, status, CertificateInfo{ID: cert.ID, State: certState(cert)})
|
||||
}
|
||||
|
||||
// certState maps a stored certificate to the wire state.
|
||||
func certState(cert *storedCert) CertState {
|
||||
switch {
|
||||
case cert == nil:
|
||||
return CertState{Type: "failed", Reason: "no certificate"}
|
||||
case certReady(cert):
|
||||
return CertState{Type: "ready", Certificate: cert.Certificate, Chain: cert.Chain, Expiry: cert.Expiry}
|
||||
case cert.State == "failed":
|
||||
return CertState{Type: "failed", Reason: cert.Reason}
|
||||
default:
|
||||
return CertState{Type: "issuing"}
|
||||
}
|
||||
}
|
||||
|
||||
// issuer returns the configured certificate issuer (the local CA by default).
|
||||
func (a *API) issuer() Issuer {
|
||||
if a.Issuer != nil {
|
||||
return a.Issuer
|
||||
}
|
||||
return NewLocalIssuer(a.Store)
|
||||
}
|
||||
|
||||
// issuerName identifies the current issuer, so certificates from a previous
|
||||
// issuer (say the local CA) are re-issued once a real one is configured.
|
||||
func (a *API) issuerName() string {
|
||||
if a.IssuerName != "" {
|
||||
return a.IssuerName
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
// runIssue issues one certificate and records the outcome. At most one
|
||||
// issuance runs per tunnel.
|
||||
func (a *API) runIssue(tunnelID, hostname, csrPEM, certID string) {
|
||||
if !a.beginIssue(tunnelID) {
|
||||
return
|
||||
}
|
||||
defer a.endIssue(tunnelID)
|
||||
|
||||
// Drop an issuance whose bind was replaced before this run started (a
|
||||
// renewal racing a new bind).
|
||||
current, ok := a.Store.get(tunnelID)
|
||||
if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM {
|
||||
log.Printf("skipping certificate issuance for %s: certificate %s is no longer current", hostname, certID)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
log.Printf("issuing certificate for %s via %s", hostname, a.issuerName())
|
||||
result, err := a.issuer().Issue(ctx, csrPEM, hostname)
|
||||
|
||||
// The tunnel may have been deleted, or bound a different CSR, while the
|
||||
// order ran. Storing this result would clobber the newer certificate.
|
||||
current, ok = a.Store.get(tunnelID)
|
||||
if !ok || current.Certificate == nil || current.Certificate.ID != certID || current.Certificate.CSR != csrPEM {
|
||||
log.Printf("discarding certificate outcome for %s: certificate %s is no longer current", hostname, certID)
|
||||
return
|
||||
}
|
||||
|
||||
cert := storedCert{ID: certID, CSR: csrPEM, Issuer: a.issuerName()}
|
||||
if err != nil {
|
||||
cert.State = "failed"
|
||||
cert.Reason = err.Error()
|
||||
if certReady(current.Certificate) {
|
||||
// A renewal failed but the old certificate is still valid and
|
||||
// serving: keep it, and let the renewal loop retry.
|
||||
kept := *current.Certificate
|
||||
kept.State = "failed"
|
||||
kept.Reason = cert.Reason
|
||||
cert = kept
|
||||
}
|
||||
log.Printf("certificate for %s failed: %v", hostname, err)
|
||||
} else {
|
||||
cert.State = "ready"
|
||||
cert.Certificate = result.Certificate
|
||||
cert.Chain = result.Chain
|
||||
cert.Expiry = expiryString(result.Expiry)
|
||||
log.Printf("certificate for %s ready (expires %s)", hostname, cert.Expiry)
|
||||
}
|
||||
if err := a.Store.bindCertificate(tunnelID, cert); err != nil {
|
||||
log.Printf("storing certificate for %s: %v", hostname, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *API) beginIssue(tunnelID string) bool {
|
||||
a.issueMu.Lock()
|
||||
defer a.issueMu.Unlock()
|
||||
if a.issuing == nil {
|
||||
a.issuing = map[string]bool{}
|
||||
}
|
||||
if a.issuing[tunnelID] {
|
||||
return false
|
||||
}
|
||||
a.issuing[tunnelID] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// startIssue runs issuance in the background for ACME, and synchronously for
|
||||
// the local CA so the next GET already answers ready.
|
||||
func (a *API) startIssue(tunnelID, hostname, csrPEM, certID string) {
|
||||
if a.issuer().Instant() {
|
||||
a.runIssue(tunnelID, hostname, csrPEM, certID)
|
||||
return
|
||||
}
|
||||
go a.runIssue(tunnelID, hostname, csrPEM, certID)
|
||||
}
|
||||
|
||||
func (a *API) isIssuing(tunnelID string) bool {
|
||||
a.issueMu.Lock()
|
||||
defer a.issueMu.Unlock()
|
||||
return a.issuing[tunnelID]
|
||||
}
|
||||
|
||||
func (a *API) endIssue(tunnelID string) {
|
||||
a.issueMu.Lock()
|
||||
defer a.issueMu.Unlock()
|
||||
delete(a.issuing, tunnelID)
|
||||
}
|
||||
|
||||
// ---- GET /api/tunnel/{id}/connect
|
||||
|
||||
Reference in New Issue
Block a user