Skip to content

Commit a97a871

Browse files
ersinkocclaude
andcommitted
feat(tls): add auto-TLS via Let's Encrypt and public DNS guide page
New certmanager package wraps autocert for automatic certificate provisioning (TLS-ALPN-01 primary, HTTP-01 fallback). Shared TLS config serves web, DoH, and DoT from a single ACME-managed cert. Public /guide page shows per-platform DNS setup instructions. Operations page gains TLS cert status card with expiry monitoring and force-renew. Config page adds auto-TLS form section. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d5a89d7 commit a97a871

17 files changed

Lines changed: 1194 additions & 33 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.5.5] - 2026-04-05
10+
11+
### Added
12+
- **Auto-TLS (Let's Encrypt)**: optional automatic certificate provisioning via ACME. Set `web.auto_tls: true` + `web.auto_tls_domain` and Labyrinth handles cert issuance, renewal, and storage. Uses TLS-ALPN-01 (primary) with HTTP-01 fallback on port 80. Staging mode available for testing (`web.auto_tls_staging`).
13+
- New `certmanager` package wrapping `golang.org/x/crypto/acme/autocert` — shared `*tls.Config` for web server, DoH, and DoT.
14+
- `NewDoTServerWithTLSConfig()` constructor for DoT server to accept a pre-built `*tls.Config` from auto-TLS instead of cert file paths.
15+
- **TLS certificate status API**: `GET /api/system/tls` returns cert domain, issuer, expiry, SAN list, and auto-TLS mode; `POST /api/system/tls/renew` forces certificate cache eviction for re-provisioning.
16+
- **Public DNS guide page** at `/guide` (no authentication required) — platform-specific setup instructions for Windows, macOS, Linux, iOS, Android, and browsers (Firefox DoH, Chrome/Edge DoH). Auto-detects server capabilities (DoH URL, DoT hostname) from `GET /api/dns-guide`.
17+
- Operations page: TLS certificate status card showing domain, issuer, expiry countdown (color-coded), SAN list, and "Force Renew" button for auto-TLS.
18+
- Config page: Auto-TLS form section (domain, email, cache dir, staging toggle) that conditionally hides manual cert file inputs when enabled.
19+
- Comprehensive test suite: 10 certmanager unit tests (New, staging, Info, ForceRenew, TLSConfig, HTTPHandler, InfoFromStatic, certIssuer), 7 web API tests (TLS status/renew, DNS guide with DoH URL construction).
20+
21+
### Changed
22+
- Config validation relaxed: `web.tls_enabled` no longer requires cert/key files when `web.auto_tls` is active; DoH3 validation also accepts auto-TLS.
23+
- DoT server startup in `main_runtime_helpers.go` now checks for shared auto-TLS config before falling back to static cert paths.
24+
925
## [0.5.4] - 2026-04-05
1026

1127
### Added

certmanager/manager.go

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Package certmanager provides automatic TLS certificate provisioning via
2+
// Let's Encrypt (ACME). It wraps golang.org/x/crypto/acme/autocert and
3+
// exposes helpers for the web server and DoT server to share certificates.
4+
package certmanager
5+
6+
import (
7+
"context"
8+
"crypto/tls"
9+
"crypto/x509"
10+
"fmt"
11+
"log/slog"
12+
"net/http"
13+
"sync"
14+
"time"
15+
16+
"golang.org/x/crypto/acme"
17+
"golang.org/x/crypto/acme/autocert"
18+
)
19+
20+
// CertInfo describes the currently active certificate.
21+
type CertInfo struct {
22+
Domain string `json:"domain"`
23+
Issuer string `json:"issuer"`
24+
Subject string `json:"subject"`
25+
NotBefore time.Time `json:"not_before"`
26+
NotAfter time.Time `json:"not_after"`
27+
DNSNames []string `json:"dns_names"`
28+
AutoTLS bool `json:"auto_tls"`
29+
ACME bool `json:"acme"`
30+
}
31+
32+
// Manager wraps autocert.Manager and provides certificate status inspection.
33+
type Manager struct {
34+
acm *autocert.Manager
35+
domain string
36+
email string
37+
logger *slog.Logger
38+
39+
mu sync.RWMutex
40+
lastCert *x509.Certificate
41+
}
42+
43+
// New creates a new certificate manager.
44+
// cacheDir is the directory where certs are stored on disk.
45+
// If staging is true, the Let's Encrypt staging endpoint is used (for testing).
46+
func New(domain, email, cacheDir string, staging bool, logger *slog.Logger) *Manager {
47+
acm := &autocert.Manager{
48+
Prompt: autocert.AcceptTOS,
49+
Cache: autocert.DirCache(cacheDir),
50+
HostPolicy: autocert.HostWhitelist(domain),
51+
Email: email,
52+
}
53+
54+
if staging {
55+
acm.Client = &acme.Client{
56+
DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory",
57+
}
58+
}
59+
60+
return &Manager{
61+
acm: acm,
62+
domain: domain,
63+
email: email,
64+
logger: logger,
65+
}
66+
}
67+
68+
// TLSConfig returns a *tls.Config that automatically provisions certificates.
69+
// Use this for both the web server and DoT server.
70+
func (m *Manager) TLSConfig() *tls.Config {
71+
tlsCfg := m.acm.TLSConfig()
72+
tlsCfg.MinVersion = tls.VersionTLS12
73+
74+
// Wrap GetCertificate to capture cert info for status reporting.
75+
origGetCert := tlsCfg.GetCertificate
76+
tlsCfg.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
77+
cert, err := origGetCert(hello)
78+
if err != nil {
79+
return nil, err
80+
}
81+
if cert != nil && cert.Leaf != nil {
82+
m.mu.Lock()
83+
m.lastCert = cert.Leaf
84+
m.mu.Unlock()
85+
} else if cert != nil && len(cert.Certificate) > 0 {
86+
if parsed, parseErr := x509.ParseCertificate(cert.Certificate[0]); parseErr == nil {
87+
m.mu.Lock()
88+
m.lastCert = parsed
89+
m.mu.Unlock()
90+
}
91+
}
92+
return cert, nil
93+
}
94+
95+
return tlsCfg
96+
}
97+
98+
// HTTPHandler returns an http.Handler that serves ACME HTTP-01 challenges
99+
// and redirects all other traffic to HTTPS. Use this on port 80.
100+
func (m *Manager) HTTPHandler(fallback http.Handler) http.Handler {
101+
return m.acm.HTTPHandler(fallback)
102+
}
103+
104+
// Info returns information about the currently active certificate.
105+
func (m *Manager) Info() *CertInfo {
106+
m.mu.RLock()
107+
leaf := m.lastCert
108+
m.mu.RUnlock()
109+
110+
if leaf == nil {
111+
return &CertInfo{
112+
Domain: m.domain,
113+
AutoTLS: true,
114+
ACME: true,
115+
}
116+
}
117+
118+
return &CertInfo{
119+
Domain: m.domain,
120+
Issuer: certIssuer(leaf),
121+
Subject: leaf.Subject.CommonName,
122+
NotBefore: leaf.NotBefore,
123+
NotAfter: leaf.NotAfter,
124+
DNSNames: leaf.DNSNames,
125+
AutoTLS: true,
126+
ACME: true,
127+
}
128+
}
129+
130+
// Domain returns the managed domain name.
131+
func (m *Manager) Domain() string {
132+
return m.domain
133+
}
134+
135+
// ForceRenew removes the cached certificate so autocert provisions a new one
136+
// on the next TLS handshake.
137+
func (m *Manager) ForceRenew(ctx context.Context) error {
138+
cache := m.acm.Cache
139+
140+
// autocert caches certs under the domain name and domain+rsa keys.
141+
_ = cache.Delete(ctx, m.domain)
142+
_ = cache.Delete(ctx, m.domain+"+rsa")
143+
_ = cache.Delete(ctx, m.domain+"+token")
144+
145+
m.mu.Lock()
146+
m.lastCert = nil
147+
m.mu.Unlock()
148+
149+
m.logger.Info("auto-tls: cached certificate removed, will re-provision on next handshake", "domain", m.domain)
150+
return nil
151+
}
152+
153+
// InfoFromStatic returns CertInfo by reading a static cert+key file pair.
154+
func InfoFromStatic(certFile, keyFile string) (*CertInfo, error) {
155+
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
156+
if err != nil {
157+
return nil, fmt.Errorf("load certificate: %w", err)
158+
}
159+
if len(cert.Certificate) == 0 {
160+
return nil, fmt.Errorf("no certificates found in %s", certFile)
161+
}
162+
leaf, err := x509.ParseCertificate(cert.Certificate[0])
163+
if err != nil {
164+
return nil, fmt.Errorf("parse certificate: %w", err)
165+
}
166+
167+
return &CertInfo{
168+
Domain: leaf.Subject.CommonName,
169+
Issuer: certIssuer(leaf),
170+
Subject: leaf.Subject.CommonName,
171+
NotBefore: leaf.NotBefore,
172+
NotAfter: leaf.NotAfter,
173+
DNSNames: leaf.DNSNames,
174+
AutoTLS: false,
175+
ACME: false,
176+
}, nil
177+
}
178+
179+
func certIssuer(leaf *x509.Certificate) string {
180+
if leaf.Issuer.CommonName != "" {
181+
return leaf.Issuer.CommonName
182+
}
183+
if len(leaf.Issuer.Organization) > 0 {
184+
return leaf.Issuer.Organization[0]
185+
}
186+
return "unknown"
187+
}

0 commit comments

Comments
 (0)