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:
2026-10-25 23:33:52 +08:00
parent 05d326f47f
commit 437a2e447e
25 changed files with 1491 additions and 79 deletions
+8
View File
@@ -18,6 +18,7 @@ type adminServer struct {
Domain string `json:"domain"`
Version string `json:"version"`
Go string `json:"go"`
Issuer string `json:"issuer"`
UptimeSeconds int64 `json:"uptime_seconds"`
}
@@ -39,6 +40,9 @@ type adminTunnel struct {
Routes []string `json:"routes"`
Visitors int `json:"visitors"`
CertificateExpiry string `json:"certificate_expiry,omitempty"`
CertificateState string `json:"certificate_state,omitempty"`
CertificateIssuer string `json:"certificate_issuer,omitempty"`
CertificateError string `json:"certificate_error,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
}
@@ -98,6 +102,7 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
Domain: a.Domain,
Version: a.Version,
Go: runtime.Version(),
Issuer: a.issuerName(),
UptimeSeconds: int64(time.Since(a.StartedAt).Seconds()),
},
CA: adminCA{Expires: a.caExpiry()},
@@ -137,6 +142,9 @@ func (a *API) adminOverviewHandler(w http.ResponseWriter, r *http.Request) {
}
if t.Certificate != nil {
entry.CertificateExpiry = t.Certificate.Expiry
entry.CertificateState = certState(t.Certificate).Type
entry.CertificateIssuer = t.Certificate.Issuer
entry.CertificateError = t.Certificate.Reason
}
overview.Tunnels = append(overview.Tunnels, entry)
}
+154 -31
View File
@@ -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
+1 -1
View File
@@ -219,7 +219,7 @@ func (h *Hub) attach(w http.ResponseWriter, r *http.Request, tunnel storedTunnel
fail("bad_token")
return
}
if tunnel.Certificate == nil {
if !certReady(tunnel.Certificate) {
fail("cert_not_ready")
return
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"context"
"fmt"
"strings"
)
// DNSProvider manages the TXT records ACME DNS-01 challenges need.
type DNSProvider interface {
// CreateTXT adds a TXT record with the given value at name.
CreateTXT(ctx context.Context, name, value string) error
// DeleteTXT removes every TXT record at name (best effort cleanup).
DeleteTXT(ctx context.Context, name string) error
}
// challengeRecordName is the TXT record name a hostname's DNS-01 challenge
// lives at; the wildcard of a hostname shares the same record.
func challengeRecordName(hostname string) string {
return "_acme-challenge." + hostname
}
// relativeRecordName strips the zone suffix from a record name, as DNS
// provider APIs expect names relative to the zone.
func relativeRecordName(name, zone string) (string, error) {
suffix := "." + zone
if !strings.HasSuffix(name, suffix) {
return "", fmt.Errorf("record %q is not under zone %q", name, zone)
}
return strings.TrimSuffix(name, suffix), nil
}
// dnsZone guesses the registrable zone that hosts the tunnel hostnames:
// the domain minus its first label when it has three or more (tunnel.tzgyn.dev
// is served by the tzgyn.dev zone). Override with DNS_ZONE when wrong.
func DNSZone(domain string) string {
labels := strings.Split(domain, ".")
if len(labels) >= 3 {
return strings.Join(labels[1:], ".")
}
return domain
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ChallengeTestSrvDNS talks to pebble-challtestsrv's management API, for
// development against a local Pebble ACME server (DNS_PROVIDER=challtestsrv).
type ChallengeTestSrvDNS struct {
BaseURL string // default http://127.0.0.1:8055
HTTP *http.Client
}
func (c *ChallengeTestSrvDNS) base() string {
if c.BaseURL != "" {
return c.BaseURL
}
return "http://127.0.0.1:8055"
}
func (c *ChallengeTestSrvDNS) client() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 10 * time.Second}
}
func (c *ChallengeTestSrvDNS) post(ctx context.Context, path string, body map[string]string) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base()+path, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("challtestsrv %s: HTTP %d: %s", path, resp.StatusCode, data)
}
return nil
}
func (c *ChallengeTestSrvDNS) CreateTXT(ctx context.Context, name, value string) error {
return c.post(ctx, "/set-txt", map[string]string{"host": name, "value": value})
}
func (c *ChallengeTestSrvDNS) DeleteTXT(ctx context.Context, name string) error {
return c.post(ctx, "/clear-txt", map[string]string{"host": name})
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestChallengeTestSrvDNS(t *testing.T) {
var setBody, clearBody map[string]string
var setPath, clearPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
switch r.URL.Path {
case "/set-txt":
setPath = r.URL.Path
_ = json.Unmarshal(body, &setBody)
case "/clear-txt":
clearPath = r.URL.Path
_ = json.Unmarshal(body, &clearBody)
default:
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := &ChallengeTestSrvDNS{BaseURL: srv.URL, HTTP: srv.Client()}
ctx := context.Background()
if err := c.CreateTXT(ctx, "_acme-challenge.abc.localhost", "value"); err != nil {
t.Fatal(err)
}
if setPath != "/set-txt" || setBody["host"] != "_acme-challenge.abc.localhost" || setBody["value"] != "value" {
t.Errorf("set: path %q body %v", setPath, setBody)
}
if err := c.DeleteTXT(ctx, "_acme-challenge.abc.localhost"); err != nil {
t.Fatal(err)
}
if clearPath != "/clear-txt" || clearBody["host"] != "_acme-challenge.abc.localhost" {
t.Errorf("clear: path %q body %v", clearPath, clearBody)
}
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no", http.StatusInternalServerError)
}))
defer bad.Close()
c = &ChallengeTestSrvDNS{BaseURL: bad.URL, HTTP: bad.Client()}
if err := c.CreateTXT(ctx, "x", "y"); err == nil {
t.Error("expected an error for a non-200 response")
}
}
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// PorkbunDNS manages TXT records through Porkbun's DNS API
// (https://api.porkbun.com/api/json/v3).
type PorkbunDNS struct {
APIKey string
SecretKey string
Zone string
BaseURL string // default https://api.porkbun.com/api/json/v3
HTTP *http.Client
}
const porkbunDefaultBaseURL = "https://api.porkbun.com/api/json/v3"
func (p *PorkbunDNS) base() string {
if p.BaseURL != "" {
return p.BaseURL
}
return porkbunDefaultBaseURL
}
func (p *PorkbunDNS) client() *http.Client {
if p.HTTP != nil {
return p.HTTP
}
return &http.Client{Timeout: 20 * time.Second}
}
func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) error {
body["apikey"] = p.APIKey
body["secretapikey"] = p.SecretKey
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data)
}
var result struct {
Status string `json:"status"`
Message string `json:"message"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("porkbun %s: unreadable response: %w", path, err)
}
if result.Status != "SUCCESS" {
return fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message)
}
return nil
}
func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error {
relative, err := relativeRecordName(name, p.Zone)
if err != nil {
return err
}
return p.post(ctx, "/dns/create/"+p.Zone, map[string]string{
"name": relative, "type": "TXT", "content": value, "ttl": "600",
})
}
func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error {
relative, err := relativeRecordName(name, p.Zone)
if err != nil {
return err
}
return p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{})
}
+74
View File
@@ -0,0 +1,74 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPorkbunDNS(t *testing.T) {
var createPath, createBody, deletePath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
switch {
case strings.HasPrefix(r.URL.Path, "/dns/create/"):
createPath, createBody = r.URL.Path, string(body)
w.Write([]byte(`{"status":"SUCCESS","id":"42"}`))
case strings.HasPrefix(r.URL.Path, "/dns/deleteByNameType/"):
deletePath = r.URL.Path
w.Write([]byte(`{"status":"SUCCESS"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
p := &PorkbunDNS{APIKey: "pk", SecretKey: "sk", Zone: "tzgyn.dev", BaseURL: srv.URL, HTTP: srv.Client()}
ctx := context.Background()
record := "_acme-challenge.abc123.tunnel.tzgyn.dev"
if err := p.CreateTXT(ctx, record, "challenge-value"); err != nil {
t.Fatal(err)
}
if createPath != "/dns/create/tzgyn.dev" {
t.Errorf("create path = %q", createPath)
}
var body map[string]string
if err := json.Unmarshal([]byte(createBody), &body); err != nil {
t.Fatal(err)
}
for key, want := range map[string]string{
"apikey": "pk", "secretapikey": "sk",
"name": "_acme-challenge.abc123.tunnel", "type": "TXT", "content": "challenge-value",
} {
if body[key] != want {
t.Errorf("create body %s = %q, want %q", key, body[key], want)
}
}
if err := p.DeleteTXT(ctx, record); err != nil {
t.Fatal(err)
}
if deletePath != "/dns/deleteByNameType/tzgyn.dev/TXT/_acme-challenge.abc123.tunnel" {
t.Errorf("delete path = %q", deletePath)
}
// A record outside the zone fails before any request.
if err := p.CreateTXT(ctx, "other.example.com", "v"); err == nil {
t.Error("expected an error for a record outside the zone")
}
// API errors surface.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"ERROR","message":"nope"}`))
}))
defer bad.Close()
p2 := &PorkbunDNS{APIKey: "pk", SecretKey: "sk", Zone: "tzgyn.dev", BaseURL: bad.URL, HTTP: bad.Client()}
if err := p2.CreateTXT(ctx, record, "v"); err == nil || !strings.Contains(err.Error(), "nope") {
t.Errorf("expected the API error, got %v", err)
}
}
+33
View File
@@ -0,0 +1,33 @@
package server
import "testing"
func TestChallengeRecordName(t *testing.T) {
if got := challengeRecordName("abc123.tunnel.tzgyn.dev"); got != "_acme-challenge.abc123.tunnel.tzgyn.dev" {
t.Errorf("challengeRecordName = %q", got)
}
}
func TestRelativeRecordName(t *testing.T) {
got, err := relativeRecordName("_acme-challenge.abc123.tunnel.tzgyn.dev", "tzgyn.dev")
if err != nil || got != "_acme-challenge.abc123.tunnel" {
t.Errorf("relativeRecordName = %q, %v", got, err)
}
if _, err := relativeRecordName("_acme-challenge.other.example.com", "tzgyn.dev"); err == nil {
t.Error("expected an error for a record outside the zone")
}
}
func TestDNSZone(t *testing.T) {
cases := map[string]string{
"tunnel.tzgyn.dev": "tzgyn.dev",
"tzgyn.dev": "tzgyn.dev",
"a.b.example.co.uk": "b.example.co.uk",
"localhost": "localhost",
}
for domain, want := range cases {
if got := DNSZone(domain); got != want {
t.Errorf("DNSZone(%q) = %q, want %q", domain, got, want)
}
}
}
+245
View File
@@ -0,0 +1,245 @@
package server
import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
"time"
)
// failingIssuer returns an error immediately, for renewal-failure tests.
type failingIssuer struct{ reason string }
func (f *failingIssuer) Instant() bool { return false }
func (f *failingIssuer) Issue(context.Context, string, string) (IssuedCert, error) {
return IssuedCert{}, errors.New(f.reason)
}
// blockingIssuer signals when an order starts and waits for release.
type blockingIssuer struct {
started chan struct{}
release chan struct{}
result IssuedCert
}
func (b *blockingIssuer) Instant() bool { return false }
func (b *blockingIssuer) Issue(ctx context.Context, _, _ string) (IssuedCert, error) {
close(b.started)
select {
case <-b.release:
return b.result, nil
case <-ctx.Done():
return IssuedCert{}, ctx.Err()
}
}
// fakeIssuer blocks until released, then returns a fixed result.
type fakeIssuer struct {
release chan struct{}
result IssuedCert
err error
}
func (f *fakeIssuer) Instant() bool { return false }
func (f *fakeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) {
select {
case <-f.release:
case <-ctx.Done():
return IssuedCert{}, ctx.Err()
}
return f.result, f.err
}
// countingIssuer records every Issue call and returns immediately.
type countingIssuer struct {
calls chan string
}
func (c *countingIssuer) Instant() bool { return false }
func (c *countingIssuer) Issue(_ context.Context, _, hostname string) (IssuedCert, error) {
c.calls <- hostname
return IssuedCert{Certificate: "CERT2", Chain: "CHAIN2", Expiry: time.Now().Add(90 * 24 * time.Hour)}, nil
}
func TestAsyncIssuance(t *testing.T) {
store, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
issuer := &fakeIssuer{
release: make(chan struct{}),
result: IssuedCert{Certificate: "CERT", Chain: "CHAIN", Expiry: time.Now().Add(90 * 24 * time.Hour)},
}
api := &API{
Store: store, Hub: NewHub(store), Domain: "opentunnel.test",
StartedAt: time.Now(), Issuer: issuer, IssuerName: "fake",
}
srv := httptest.NewServer(api)
defer srv.Close()
tunnel := createTunnel(t, srv)
csr := makeCSR(t, tunnel.Hostname, tunnel.Hostname, "*."+tunnel.Hostname)
resp := request(t, "POST", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "application/json", `{"csr":`+jsonString(csr)+`}`)
if body := bodyString(t, resp); resp.StatusCode != 202 || !strings.Contains(body, `"type":"issuing"`) {
t.Fatalf("bind: %d %q", resp.StatusCode, body)
}
// While the issuer holds, the certificate stays "issuing".
resp = request(t, "GET", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "")
if body := bodyString(t, resp); !strings.Contains(body, `"type":"issuing"`) {
t.Fatalf("before release: %q", body)
}
close(issuer.release)
deadline := time.Now().Add(3 * time.Second)
var last string
for time.Now().Before(deadline) {
resp = request(t, "GET", srv.URL+"/api/tunnel/"+tunnel.ID+"/certificate", tunnel.Token, "", "")
last = bodyString(t, resp)
if strings.Contains(last, `"type":"ready"`) {
if !strings.Contains(last, "CERT") || !strings.Contains(last, "CHAIN") {
t.Errorf("ready certificate incomplete: %q", last)
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("certificate never became ready; last: %q", last)
}
func TestRenewalMigratesForeignCertificates(t *testing.T) {
store, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
tunnel, _, err := store.createTunnel("opentunnel.test")
if err != nil {
t.Fatal(err)
}
// A ready certificate from the local CA that expires far in the future:
// only the issuer mismatch should trigger a re-issue.
if err := store.bindCertificate(tunnel.ID, storedCert{
ID: "cert_old", CSR: "csr-pem", Issuer: "local", State: "ready",
Certificate: "OLDCERT", Chain: "OLDCHAIN",
Expiry: expiryString(time.Now().Add(300 * 24 * time.Hour)),
}); err != nil {
t.Fatal(err)
}
issuer := &countingIssuer{calls: make(chan string, 4)}
api := &API{Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(), Issuer: issuer, IssuerName: "fake"}
api.renewDue(false) // synchronous: the fake returns immediately
select {
case host := <-issuer.calls:
if host != tunnel.Hostname {
t.Errorf("issued for %q, want %q", host, tunnel.Hostname)
}
default:
t.Fatal("expected a re-issue for the foreign certificate")
}
updated, _ := store.get(tunnel.ID)
if updated.Certificate == nil || updated.Certificate.Certificate != "CERT2" || updated.Certificate.Issuer != "fake" {
t.Errorf("certificate not replaced: %+v", updated.Certificate)
}
}
func TestFailedRenewalKeepsServingCertificate(t *testing.T) {
store, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
tunnel, _, err := store.createTunnel("opentunnel.test")
if err != nil {
t.Fatal(err)
}
// Already inside the renewal window, from the current issuer.
if err := store.bindCertificate(tunnel.ID, storedCert{
ID: "cert_old", CSR: "csr-pem", Issuer: "fake", State: "ready",
Certificate: "OLDCERT", Chain: "OLDCHAIN",
Expiry: expiryString(time.Now().Add(10 * 24 * time.Hour)),
}); err != nil {
t.Fatal(err)
}
api := &API{
Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(),
Issuer: &failingIssuer{reason: "dns unavailable"}, IssuerName: "fake",
}
api.renewDue(false)
updated, _ := store.get(tunnel.ID)
cert := updated.Certificate
if cert == nil || cert.Certificate != "OLDCERT" || cert.Chain != "OLDCHAIN" || cert.Expiry == "" {
t.Fatalf("failed renewal must keep the old certificate: %+v", cert)
}
if cert.State != "failed" || cert.Reason != "dns unavailable" {
t.Errorf("failure not recorded: %+v", cert)
}
if got := certState(cert).Type; got != "ready" {
t.Errorf("clients should still see the old certificate ready, got %q", got)
}
// The renewal loop retries a failed renewal.
cert.State = "failed"
api.renewDue(false)
if updated, _ = store.get(tunnel.ID); updated.Certificate.Certificate != "OLDCERT" {
t.Errorf("retry lost the old certificate: %+v", updated.Certificate)
}
}
func TestStaleIssuanceDoesNotClobberNewBind(t *testing.T) {
store, err := OpenStore("")
if err != nil {
t.Fatal(err)
}
tunnel, _, err := store.createTunnel("opentunnel.test")
if err != nil {
t.Fatal(err)
}
if err := store.bindCertificate(tunnel.ID, storedCert{
ID: "cert_a", CSR: "CSR_A", State: "issuing", Issuer: "fake",
}); err != nil {
t.Fatal(err)
}
issuer := &blockingIssuer{
started: make(chan struct{}),
release: make(chan struct{}),
result: IssuedCert{Certificate: "CERTS_A", Chain: "CHAIN_A", Expiry: time.Now().Add(90 * 24 * time.Hour)},
}
api := &API{
Store: store, Hub: NewHub(store), Domain: "opentunnel.test", StartedAt: time.Now(),
Issuer: issuer, IssuerName: "fake",
}
done := make(chan struct{})
go func() {
api.runIssue(tunnel.ID, tunnel.Hostname, "CSR_A", "cert_a")
close(done)
}()
<-issuer.started
// The client binds a new key while the old order is still running.
if err := store.bindCertificate(tunnel.ID, storedCert{
ID: "cert_b", CSR: "CSR_B", State: "ready", Issuer: "fake",
Certificate: "CERT_B", Chain: "CHAIN_B", Expiry: expiryString(time.Now().Add(90 * 24 * time.Hour)),
}); err != nil {
t.Fatal(err)
}
close(issuer.release)
<-done
updated, _ := store.get(tunnel.ID)
if updated.Certificate == nil || updated.Certificate.ID != "cert_b" || updated.Certificate.Certificate != "CERT_B" {
t.Errorf("stale issuance overwrote the new bind: %+v", updated.Certificate)
}
}
+391
View File
@@ -0,0 +1,391 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/acme"
)
// IssuedCert is a certificate produced for a tunnel's CSR.
type IssuedCert struct {
Certificate string // leaf PEM
Chain string // issuer chain PEM
Expiry time.Time
}
// Issuer turns a tunnel's CSR into a certificate. The local CA is instant;
// ACME takes a DNS-01 round trip, so callers run those asynchronously.
type Issuer interface {
Instant() bool
Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error)
}
// ---- local CA issuer (development and self-hosting without ACME).
type localIssuer struct{ store *Store }
// NewLocalIssuer signs tunnel CSRs with the store's built-in CA.
func NewLocalIssuer(store *Store) Issuer { return &localIssuer{store: store} }
func (l *localIssuer) Instant() bool { return true }
func (l *localIssuer) Issue(_ context.Context, csrPEM, hostname string) (IssuedCert, error) {
ca, err := l.store.ca()
if err != nil {
return IssuedCert{}, err
}
certPEM, chainPEM, expiry, err := ca.signCSR(csrPEM, hostname)
if err != nil {
return IssuedCert{}, err
}
return IssuedCert{Certificate: string(certPEM), Chain: string(chainPEM), Expiry: expiry}, nil
}
// ---- ACME issuer (Let's Encrypt, ZeroSSL, Pebble) with DNS-01 challenges.
type ACMEOptions struct {
DirectoryURL string
Email string
CABundleFile string // extra roots for the ACME server's TLS (Pebble)
DNS DNSProvider
// PropagationTimeout waits until public resolvers see the challenge
// records; 0 skips the check (used with pebble-challtestsrv).
PropagationTimeout time.Duration
}
type acmeIssuer struct {
store *Store
opts ACMEOptions
sem chan struct{} // bounds concurrent ACME orders
accountMu sync.Mutex // serializes first-use account registration
}
// NewACMEIssuer returns an issuer that orders certificates over ACME and
// answers DNS-01 challenges through the given DNS provider.
func NewACMEIssuer(store *Store, opts ACMEOptions) (Issuer, error) {
if opts.DirectoryURL == "" {
return nil, errors.New("acme: directory URL is required")
}
if opts.DNS == nil {
return nil, errors.New("acme: a DNS provider is required")
}
return &acmeIssuer{store: store, opts: opts, sem: make(chan struct{}, 2)}, nil
}
func (a *acmeIssuer) Instant() bool { return false }
// client returns an ACME client with the persisted account (registering one
// on first use).
func (a *acmeIssuer) client(ctx context.Context) (*acme.Client, error) {
a.accountMu.Lock()
defer a.accountMu.Unlock()
keyPEM, accountURL, directory := a.store.acmeAccount()
storedKey, storedURL, storedDirectory := keyPEM, accountURL, directory
if directory != a.opts.DirectoryURL {
// The persisted account belongs to another ACME server (for example
// staging); register it with this directory instead.
accountURL = ""
}
var key *ecdsa.PrivateKey
if keyPEM != "" {
block, _ := pem.Decode([]byte(keyPEM))
if block == nil {
return nil, errors.New("acme: unreadable account key")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("acme: account key: %w", err)
}
ec, ok := parsed.(*ecdsa.PrivateKey)
if !ok {
return nil, errors.New("acme: account key is not ECDSA")
}
key = ec
} else {
generated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
key = generated
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
httpClient := &http.Client{Timeout: 60 * time.Second}
if a.opts.CABundleFile != "" {
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
bundle, err := os.ReadFile(a.opts.CABundleFile)
if err != nil {
return nil, fmt.Errorf("acme: CA bundle: %w", err)
}
if !pool.AppendCertsFromPEM(bundle) {
return nil, errors.New("acme: CA bundle has no certificates")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
httpClient.Transport = transport
}
client := &acme.Client{Key: key, DirectoryURL: a.opts.DirectoryURL, HTTPClient: httpClient}
if accountURL != "" {
// Reuse the persisted account without a GetAccount round trip.
client.KID = acme.KeyID(accountURL)
}
if accountURL == "" {
account := &acme.Account{}
if a.opts.Email != "" {
account.Contact = []string{"mailto:" + a.opts.Email}
}
registered, err := client.Register(ctx, account, acme.AcceptTOS)
switch {
case errors.Is(err, acme.ErrAccountAlreadyExists):
// The key is already registered with this CA; Register cached the
// account URL in client.KID.
accountURL = string(client.KID)
case err != nil:
return nil, fmt.Errorf("acme: register account: %w", err)
default:
accountURL = registered.URI
}
}
if keyPEM != storedKey || accountURL != storedURL || a.opts.DirectoryURL != storedDirectory {
if err := a.store.setACMEAccount(keyPEM, accountURL, a.opts.DirectoryURL); err != nil {
return nil, err
}
}
return client, nil
}
// Issue orders a certificate for the tunnel's names (hostname and, when the
// CSR asks for it, its wildcard) using one DNS-01 challenge record shared by
// both, then returns the certificate for the client's key.
func (a *acmeIssuer) Issue(ctx context.Context, csrPEM, hostname string) (IssuedCert, error) {
csr, err := parseCSR(csrPEM, hostname)
if err != nil {
return IssuedCert{}, err
}
select {
case a.sem <- struct{}{}:
case <-ctx.Done():
return IssuedCert{}, ctx.Err()
}
defer func() { <-a.sem }()
client, err := a.client(ctx)
if err != nil {
return IssuedCert{}, err
}
ids := []acme.AuthzID{{Type: "dns", Value: hostname}}
for _, name := range csr.DNSNames {
if name == "*."+hostname {
ids = append(ids, acme.AuthzID{Type: "dns", Value: name})
}
}
order, err := client.AuthorizeOrder(ctx, ids)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: authorize order: %w", err)
}
if order.URI == "" || order.FinalizeURL == "" {
return IssuedCert{}, fmt.Errorf("acme: order is missing its URI or finalize URL (status %q)", order.Status)
}
record := challengeRecordName(hostname)
var values []string
created := false
defer func() {
if !created {
return
}
// Best-effort cleanup, even when the attempt failed or the context is
// done; a leftover record would block the next attempt.
cleanCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := a.opts.DNS.DeleteTXT(cleanCtx, record); err != nil {
log.Printf("acme: cleanup TXT %s: %v", record, err)
}
}()
for _, authzURL := range order.AuthzURLs {
authz, err := client.GetAuthorization(ctx, authzURL)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err)
}
if authz.Status == acme.StatusValid {
continue
}
if authz.Status != acme.StatusPending {
return IssuedCert{}, authzFailure(authz)
}
challenge := dns01Challenge(authz)
if challenge == nil {
return IssuedCert{}, fmt.Errorf("acme: no dns-01 challenge for %s", authz.Identifier.Value)
}
value, err := client.DNS01ChallengeRecord(challenge.Token)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: challenge record: %w", err)
}
if err := a.opts.DNS.CreateTXT(ctx, record, value); err != nil {
return IssuedCert{}, fmt.Errorf("dns: create %s: %w", record, err)
}
created = true
values = append(values, value)
}
if a.opts.PropagationTimeout > 0 {
if err := waitForTXT(ctx, record, values, a.opts.PropagationTimeout); err != nil {
return IssuedCert{}, err
}
}
for _, authzURL := range order.AuthzURLs {
authz, err := client.GetAuthorization(ctx, authzURL)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: read authorization: %w", err)
}
if authz.Status == acme.StatusValid {
continue
}
if authz.Status != acme.StatusPending {
return IssuedCert{}, authzFailure(authz)
}
challenge := dns01Challenge(authz)
if challenge == nil {
continue
}
if _, err := client.Accept(ctx, challenge); err != nil {
return IssuedCert{}, fmt.Errorf("acme: accept challenge: %w", err)
}
if _, err := client.WaitAuthorization(ctx, authzURL); err != nil {
return IssuedCert{}, fmt.Errorf("acme: authorization: %w", err)
}
}
// All authorizations are satisfied; wait for the CA to mark the order
// ready before submitting the CSR (Let's Encrypt rejects finalization
// while the order is still pending).
order, err = client.WaitOrder(ctx, order.URI)
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: wait order: %w", err)
}
var der [][]byte
if order.Status == acme.StatusValid && order.CertURL != "" {
der, err = client.FetchCert(ctx, order.CertURL, true)
} else {
der, _, err = client.CreateCertFromOrder(ctx, order, csr.Raw, true)
}
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: finalize order: %w", err)
}
if len(der) == 0 {
return IssuedCert{}, errors.New("acme: empty certificate chain")
}
leaf, err := x509.ParseCertificate(der[0])
if err != nil {
return IssuedCert{}, fmt.Errorf("acme: parse certificate: %w", err)
}
var certPEM, chainPEM strings.Builder
for i, certDER := range der {
block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if i == 0 {
certPEM.Write(block)
} else {
chainPEM.Write(block)
}
}
return IssuedCert{Certificate: certPEM.String(), Chain: chainPEM.String(), Expiry: leaf.NotAfter}, nil
}
func dns01Challenge(authz *acme.Authorization) *acme.Challenge {
for _, challenge := range authz.Challenges {
if challenge.Type == "dns-01" {
return challenge
}
}
return nil
}
// authzFailure explains why an authorization cannot be used, including the
// CA's validation error when it reported one.
func authzFailure(authz *acme.Authorization) error {
for _, challenge := range authz.Challenges {
if challenge.Error != nil {
return fmt.Errorf("acme: authorization for %s is %s: %w", authz.Identifier.Value, authz.Status, challenge.Error)
}
}
return fmt.Errorf("acme: authorization for %s is %s", authz.Identifier.Value, authz.Status)
}
// waitForTXT polls until every challenge value is visible at the record name.
// It tries the system resolver first, then public resolvers directly, so a
// cached negative answer on the container's resolver cannot hide a record
// that the CA will see.
func waitForTXT(ctx context.Context, record string, values []string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
resolvers := []*net.Resolver{net.DefaultResolver, resolverAt("1.1.1.1:53"), resolverAt("8.8.8.8:53")}
for {
for _, resolver := range resolvers {
queryCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
seen, err := resolver.LookupTXT(queryCtx, record)
cancel()
if err != nil {
continue
}
present := 0
for _, want := range values {
for _, got := range seen {
if got == want {
present++
break
}
}
}
if present == len(values) {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("dns: TXT record %s did not propagate within %s", record, timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
// resolverAt queries one DNS server directly instead of the system resolver.
func resolverAt(address string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
dialer := net.Dialer{Timeout: 4 * time.Second}
return dialer.DialContext(ctx, network, address)
},
}
}
+26 -16
View File
@@ -120,29 +120,39 @@ func (e errCSRHostname) Error() string {
return "CSR hostname " + e.provided + " does not match tunnel hostname " + e.expected
}
// parseCSR decodes and validates a tunnel CSR: the common name must be the
// tunnel hostname and every DNS name must be the hostname or its wildcard.
func parseCSR(csrPEM, hostname string) (*x509.CertificateRequest, error) {
block, _ := pem.Decode([]byte(csrPEM))
if block == nil || block.Type != "CERTIFICATE REQUEST" {
return nil, errors.New("not a PEM certificate request")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, err
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("CSR signature: %w", err)
}
if csr.Subject.CommonName != hostname {
return nil, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname}
}
for _, name := range csr.DNSNames {
if name != hostname && name != "*."+hostname {
return nil, errCSRHostname{provided: name, expected: hostname}
}
}
return csr, nil
}
// signCSR validates a tunnel CSR and issues the certificate. The rules mirror
// the reference's job runner: the common name must be the tunnel hostname and
// every DNS name must be the hostname or its wildcard.
func (ca *localCA) signCSR(csrPEM, hostname string) (certPEM, chainPEM []byte, expiry time.Time, err error) {
block, _ := pem.Decode([]byte(csrPEM))
if block == nil || block.Type != "CERTIFICATE REQUEST" {
return nil, nil, time.Time{}, errors.New("not a PEM certificate request")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
csr, err := parseCSR(csrPEM, hostname)
if err != nil {
return nil, nil, time.Time{}, err
}
if err := csr.CheckSignature(); err != nil {
return nil, nil, time.Time{}, fmt.Errorf("CSR signature: %w", err)
}
if csr.Subject.CommonName != hostname {
return nil, nil, time.Time{}, errCSRHostname{provided: csr.Subject.CommonName, expected: hostname}
}
for _, name := range csr.DNSNames {
if name != hostname && name != "*."+hostname {
return nil, nil, time.Time{}, errCSRHostname{provided: name, expected: hostname}
}
}
expiry = time.Now().Add(90 * 24 * time.Hour)
der, err := ca.sign(&x509.Certificate{
SerialNumber: newSerial(),
+1 -1
View File
@@ -53,7 +53,7 @@ func (r *Relay) handle(conn net.Conn) {
return
}
tunnel, ok := r.Store.get(tunnelID)
if !ok || tunnel.Certificate == nil {
if !ok || !certReady(tunnel.Certificate) {
log.Printf("relay: %s: tunnel %s unknown or certificate not ready", conn.RemoteAddr(), tunnelID)
conn.Close() // unknown tunnel or certificate not ready
return
+70
View File
@@ -0,0 +1,70 @@
package server
import (
"context"
"log"
"time"
)
// renewalWindow re-issues certificates this long before they expire.
const renewalWindow = 30 * 24 * time.Hour
// StartRenewal keeps certificates fresh: it re-issues those close to expiry,
// retries failures, recovers issuances interrupted by a restart, and migrates
// certificates from a previous issuer (say the local CA) once a real issuer
// is configured. It runs at startup and every 6 hours.
func (a *API) StartRenewal(ctx context.Context) {
go func() {
a.renewDue(true)
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.renewDue(false)
}
}
}()
}
func (a *API) renewDue(firstRun bool) {
current := a.issuerName()
for _, tunnel := range a.Store.Tunnels() {
cert := tunnel.Certificate
if cert == nil || cert.CSR == "" {
continue
}
switch {
case cert.State == "issuing":
// An in-flight issuance cannot survive a restart; retry on the
// first pass after startup.
if firstRun {
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
case certReady(cert):
needs := normalizedIssuer(cert.Issuer) != current
if !needs && cert.Expiry != "" {
if expiry, err := time.Parse(time.RFC3339, cert.Expiry); err == nil {
needs = time.Until(expiry) < renewalWindow
}
}
if needs {
log.Printf("renewing certificate for %s (issuer %q, expires %s)", tunnel.Hostname, cert.Issuer, cert.Expiry)
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
case cert.State == "failed":
a.runIssue(tunnel.ID, tunnel.Hostname, cert.CSR, cert.ID)
}
}
}
// normalizedIssuer treats certificates from before the issuer was recorded
// as local-CA certificates.
func normalizedIssuer(issuer string) string {
if issuer == "" {
return "local"
}
return issuer
}
+40 -10
View File
@@ -12,14 +12,23 @@ import (
"time"
)
// storedCert is one issued certificate. With the local issuer, issuance is
// synchronous, so a bound CSR always has its certificate populated.
// storedCert is one certificate for a tunnel. Issuance is asynchronous with
// ACME: the cert starts as "issuing", and either becomes ready (Certificate
// set) or failed with a reason.
type storedCert struct {
ID string `json:"id"`
CSR string `json:"csr"`
Certificate string `json:"certificate"`
Chain string `json:"chain"`
Expiry string `json:"expiry"`
State string `json:"state,omitempty"` // "issuing" | "failed"; empty or Certificate set means ready
Reason string `json:"reason,omitempty"` // when State is "failed"
Issuer string `json:"issuer,omitempty"` // "local" or the ACME directory URL
Certificate string `json:"certificate,omitempty"`
Chain string `json:"chain,omitempty"`
Expiry string `json:"expiry,omitempty"`
}
// certReady reports whether the certificate can serve TLS now.
func certReady(cert *storedCert) bool {
return cert != nil && cert.Certificate != ""
}
type storedTunnel struct {
@@ -33,11 +42,14 @@ type storedTunnel struct {
// storeData is the on-disk shape. The CA key is a secret, so the file is
// written 0600.
type storeData struct {
CAKeyPEM string `json:"caKeyPem"`
CACertPEM string `json:"caCertPEM"`
ServerKey string `json:"serverKey"`
ServerCrt string `json:"serverCrt"`
Tunnels map[string]storedTunnel `json:"tunnels"`
CAKeyPEM string `json:"caKeyPem"`
CACertPEM string `json:"caCertPEM"`
ServerKey string `json:"serverKey"`
ServerCrt string `json:"serverCrt"`
ACMEKey string `json:"acmeKey,omitempty"`
ACMEAccountURL string `json:"acmeAccountUrl,omitempty"`
ACMEDirectory string `json:"acmeDirectory,omitempty"`
Tunnels map[string]storedTunnel `json:"tunnels"`
}
// Store is a JSON-file-backed tunnel store: adequate for a single-node
@@ -170,6 +182,24 @@ func (s *Store) bindCertificate(id string, cert storedCert) error {
return s.saveLocked()
}
// acmeAccount returns the persisted ACME account key (PEM), account URL, and
// the directory they belong to; any of them may be empty before first use.
func (s *Store) acmeAccount() (keyPEM, accountURL, directory string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.ACMEKey, s.data.ACMEAccountURL, s.data.ACMEDirectory
}
// setACMEAccount persists the ACME account key, URL, and directory.
func (s *Store) setACMEAccount(keyPEM, accountURL, directory string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.ACMEKey = keyPEM
s.data.ACMEAccountURL = accountURL
s.data.ACMEDirectory = directory
return s.saveLocked()
}
// remove deletes the tunnel.
func (s *Store) remove(id string) error {
s.mu.Lock()