Files
tunnel/internal/server/issue_test.go
T
TZGyn 437a2e447e 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.
2026-10-25 23:33:52 +08:00

246 lines
7.4 KiB
Go

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)
}
}