Files
tunnel/internal/server/store.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

239 lines
6.8 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// 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"`
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 {
ID string `json:"id"`
Hostname string `json:"hostname"`
TokenHash string `json:"tokenHash"`
CreatedAt string `json:"createdAt,omitempty"`
Certificate *storedCert `json:"certificate,omitempty"`
}
// 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"`
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
// self-hosted deployment, and the one writer this server has.
type Store struct {
mu sync.Mutex
path string
data storeData
}
// openStore loads path, or initializes it (with a fresh local CA) when it
// does not exist. An empty path keeps state in memory only.
func OpenStore(path string) (*Store, error) {
s := &Store{path: path, data: storeData{Tunnels: map[string]storedTunnel{}}}
if path != "" {
if raw, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(raw, &s.data); err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
if s.data.Tunnels == nil {
s.data.Tunnels = map[string]storedTunnel{}
}
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
}
if s.data.CAKeyPEM == "" {
ca, err := newLocalCA()
if err != nil {
return nil, err
}
keyPEM, err := ca.keyPEM()
if err != nil {
return nil, err
}
s.data.CAKeyPEM = string(keyPEM)
s.data.CACertPEM = string(ca.certPEM)
if err := s.saveLocked(); err != nil {
return nil, err
}
}
return s, nil
}
// ca returns the store's local CA, loading it from the stored PEM.
func (s *Store) ca() (*localCA, error) {
s.mu.Lock()
defer s.mu.Unlock()
return loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM))
}
// CACertPEM is the CA certificate clients should trust.
func (s *Store) CACertPEM() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.CACertPEM
}
// serverCertificate returns the API domain's key and certificate PEM,
// issuing one with the CA when missing.
func (s *Store) ServerCertificate(domain string) (certPEM, keyPEM []byte, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.data.ServerCrt != "" {
return []byte(s.data.ServerCrt), []byte(s.data.ServerKey), nil
}
ca, err := loadLocalCA([]byte(s.data.CAKeyPEM), []byte(s.data.CACertPEM))
if err != nil {
return nil, nil, err
}
certPEM, keyPEM, err = ca.issue(domain)
if err != nil {
return nil, nil, err
}
s.data.ServerCrt = string(certPEM)
s.data.ServerKey = string(keyPEM)
if err := s.saveLocked(); err != nil {
return nil, nil, err
}
return certPEM, keyPEM, nil
}
// createTunnel stores a new tunnel and returns it with its bearer token, the
// only time the token is visible.
func (s *Store) createTunnel(domain string) (storedTunnel, string, error) {
token := newToken()
t := storedTunnel{
ID: slug(),
TokenHash: hashToken(token),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
t.Hostname = t.ID + "." + domain
s.mu.Lock()
defer s.mu.Unlock()
s.data.Tunnels[t.ID] = t
return t, token, s.saveLocked()
}
// get returns the tunnel with id, or false.
func (s *Store) get(id string) (storedTunnel, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.data.Tunnels[id]
return t, ok
}
// Tunnels returns every tunnel, sorted by hostname (for the admin dashboard).
func (s *Store) Tunnels() []storedTunnel {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]storedTunnel, 0, len(s.data.Tunnels))
for _, t := range s.data.Tunnels {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Hostname < out[j].Hostname })
return out
}
// bindCSR records the issued certificate for the tunnel. The CSR is kept so a
// renewal (and a resumed provision) can be answered without a new bind.
func (s *Store) bindCertificate(id string, cert storedCert) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.data.Tunnels[id]
if !ok {
return errors.New("tunnel not found")
}
t.Certificate = &cert
s.data.Tunnels[id] = t
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()
defer s.mu.Unlock()
if _, ok := s.data.Tunnels[id]; !ok {
return errors.New("tunnel not found")
}
delete(s.data.Tunnels, id)
return s.saveLocked()
}
// saveLocked writes the data file atomically (0600); a no-op for an
// in-memory store.
func (s *Store) saveLocked() error {
if s.path == "" {
return nil
}
raw, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// expiryString formats a time as the reference does: ISO 8601 UTC with
// milliseconds, e.g. 2026-10-03T00:00:00.000Z.
func expiryString(t time.Time) string {
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}