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.
246 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|