mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 21:54:14 +00:00
feat(namespace): reuse caddy wildcard certificate for stealth turns
- Implement `resolveStealthCert` to use existing `*.<baseDomain>` wildcard certificates instead of dynamic Caddyfile provisioning. - Avoids EROFS errors caused by `ProtectSystem=strict` on the orama-node service. - Add strict validation to ensure stealth hosts are single-label subdomains covered by the wildcard.
This commit is contained in:
parent
37daf28b5a
commit
8375d92109
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
production "github.com/DeBrosOfficial/network/pkg/environments/production"
|
||||
@ -582,6 +583,53 @@ func (s *SystemdSpawner) resolveTURNSCert(namespace, domain, publicIP, configDir
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
// resolveStealthCert resolves the TLS cert/key for the stealth TURNS host by
|
||||
// reusing Caddy's existing `*.<baseDomain>` wildcard certificate (feat-124).
|
||||
//
|
||||
// The stealth host is a single-label subdomain of the base domain
|
||||
// (cdn-<hash>.<baseDomain>), so the wildcard the gateway already provisions
|
||||
// for HTTPS covers it. This deliberately avoids the runtime
|
||||
// append-to-Caddyfile provisioning path: the orama-node service runs
|
||||
// ProtectSystem=strict as the orama user and cannot write /etc/caddy, so that
|
||||
// path fails with EROFS (and would silently fall back to a self-signed cert
|
||||
// that clients reject — indistinguishable from being blocked). Caddy renews
|
||||
// the wildcard; the TURN cert reloader hot-reloads it from storage.
|
||||
//
|
||||
// Hard error (never self-signed) when the wildcard is missing or the host is
|
||||
// not a single-label subdomain — a stealth endpoint with an unvalidatable
|
||||
// cert is worse than no stealth endpoint.
|
||||
func (s *SystemdSpawner) resolveStealthCert(stealthDomain, baseDomain string) (string, string, error) {
|
||||
if baseDomain == "" {
|
||||
return "", "", fmt.Errorf("stealth cert: base domain required")
|
||||
}
|
||||
if !isSingleLabelSubdomain(stealthDomain, baseDomain) {
|
||||
return "", "", fmt.Errorf("stealth cert: %q is not a single-label subdomain of %q (the *.%s wildcard cert would not cover it)", stealthDomain, baseDomain, baseDomain)
|
||||
}
|
||||
certPath, keyPath := caddyWildcardCertPaths(baseDomain)
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
return "", "", fmt.Errorf("stealth cert: Caddy wildcard cert for *.%s not found at %s (is the gateway HTTPS wildcard provisioned on this node?): %w", baseDomain, certPath, err)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
return "", "", fmt.Errorf("stealth cert: Caddy wildcard key for *.%s not found at %s: %w", baseDomain, keyPath, err)
|
||||
}
|
||||
s.logger.Info("Using Caddy wildcard cert for stealth TURNS",
|
||||
zap.String("stealth_domain", stealthDomain),
|
||||
zap.String("cert_path", certPath))
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
// isSingleLabelSubdomain reports whether host is exactly one DNS label below
|
||||
// base (e.g. "cdn-x.example.com" under "example.com"), which is the set a
|
||||
// `*.base` wildcard certificate covers.
|
||||
func isSingleLabelSubdomain(host, base string) bool {
|
||||
suffix := "." + base
|
||||
if !strings.HasSuffix(host, suffix) {
|
||||
return false
|
||||
}
|
||||
label := strings.TrimSuffix(host, suffix)
|
||||
return label != "" && !strings.Contains(label, ".")
|
||||
}
|
||||
|
||||
// SpawnTURN starts a TURN instance using systemd
|
||||
func (s *SystemdSpawner) SpawnTURN(ctx context.Context, namespace, nodeID string, cfg TURNInstanceConfig) error {
|
||||
s.logger.Info("Spawning TURN via systemd",
|
||||
@ -620,25 +668,24 @@ func (s *SystemdSpawner) SpawnTURN(ctx context.Context, namespace, nodeID string
|
||||
var stealthCertPath, stealthKeyPath string
|
||||
if cfg.StealthDomain != "" {
|
||||
// Security: the stealth domain arrives over the spawn protocol (mesh
|
||||
// peers gated only by the static internal-auth header). Before it
|
||||
// reaches the Caddyfile/ACME sink, pin it to the deterministic
|
||||
// derivation so a forged value can't drive cert issuance for an
|
||||
// attacker-chosen name. cfg.Realm is the base domain on every TURN
|
||||
// spawn site. (provisionTURNCertViaCaddy adds a DNS-name allowlist as
|
||||
// defense-in-depth.)
|
||||
if cfg.Realm != "" {
|
||||
want := turn.StealthHostForNamespace(cfg.Namespace, cfg.Realm)
|
||||
if cfg.StealthDomain != want {
|
||||
return fmt.Errorf("stealth domain %q does not match the derived host %q for namespace %s — refusing to provision", cfg.StealthDomain, want, cfg.Namespace)
|
||||
}
|
||||
// peers gated only by the static internal-auth header). Pin it to the
|
||||
// deterministic derivation so a forged value can't select cert
|
||||
// material for an attacker-chosen name. cfg.Realm is the base domain
|
||||
// on every TURN spawn site.
|
||||
if cfg.Realm == "" {
|
||||
return fmt.Errorf("stealth TURNS for namespace %s requires a base domain (realm) to locate the wildcard cert", namespace)
|
||||
}
|
||||
want := turn.StealthHostForNamespace(cfg.Namespace, cfg.Realm)
|
||||
if cfg.StealthDomain != want {
|
||||
return fmt.Errorf("stealth domain %q does not match the derived host %q for namespace %s — refusing to provision", cfg.StealthDomain, want, cfg.Namespace)
|
||||
}
|
||||
if cfg.TURNSListenAddr == "" {
|
||||
return fmt.Errorf("stealth TURNS for namespace %s requires an active TURNS listener (no TLS cert/listener available)", namespace)
|
||||
}
|
||||
var stealthErr error
|
||||
stealthCertPath, stealthKeyPath, stealthErr = s.resolveTURNSCert(namespace, cfg.StealthDomain, cfg.PublicIP, configDir, false)
|
||||
stealthCertPath, stealthKeyPath, stealthErr = s.resolveStealthCert(cfg.StealthDomain, cfg.Realm)
|
||||
if stealthErr != nil {
|
||||
return fmt.Errorf("failed to provision stealth TURNS cert for namespace %s: %w", namespace, stealthErr)
|
||||
return fmt.Errorf("failed to resolve stealth TURNS cert for namespace %s: %w", namespace, stealthErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,10 +25,30 @@ const (
|
||||
// Caddy stores ACME certs under this directory relative to its data dir.
|
||||
caddyACMECertDir = "certificates/acme-v02.api.letsencrypt.org-directory"
|
||||
|
||||
// caddyServiceStorageDir is where the Caddy systemd service (User=orama,
|
||||
// HOME=/var/lib/caddy) actually persists its ACME certificates on a node.
|
||||
// The orama-node service runs ProtectSystem=strict and cannot write
|
||||
// /etc/caddy, so the runtime "append-to-Caddyfile" provisioning path
|
||||
// (provisionTURNCertViaCaddy) fails with EROFS — TURNS cert material is
|
||||
// instead reused from this directory (see caddyWildcardCertPaths).
|
||||
caddyServiceStorageDir = "/var/lib/caddy/caddy"
|
||||
|
||||
turnCertBeginMarker = "# BEGIN TURN CERT: "
|
||||
turnCertEndMarker = "# END TURN CERT: "
|
||||
)
|
||||
|
||||
// caddyWildcardCertPaths returns the cert/key file paths for the
|
||||
// `*.<baseDomain>` wildcard certificate in the Caddy service's storage. Caddy
|
||||
// names the wildcard directory `wildcard_.<baseDomain>`. The gateway already
|
||||
// provisions this wildcard for HTTPS, so a single-label subdomain of the base
|
||||
// domain (e.g. the stealth TURNS host `cdn-<hash>.<baseDomain>`) is covered by
|
||||
// it without any per-domain provisioning.
|
||||
func caddyWildcardCertPaths(baseDomain string) (certPath, keyPath string) {
|
||||
name := "wildcard_." + baseDomain
|
||||
dir := filepath.Join(caddyServiceStorageDir, caddyACMECertDir, name)
|
||||
return filepath.Join(dir, name+".crt"), filepath.Join(dir, name+".key")
|
||||
}
|
||||
|
||||
// provisionTURNCertViaCaddy appends the TURN domain to the local Caddyfile,
|
||||
// reloads Caddy to trigger DNS-01 ACME certificate provisioning, and waits
|
||||
// for the cert files to appear. Returns the cert/key paths on success.
|
||||
|
||||
@ -106,3 +106,70 @@ func TestProvisionTURNCertViaCaddy_rejectsNonDNSName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feat-124 stealth cert reuse: the stealth TURNS host reuses Caddy's existing
|
||||
// *.<base> wildcard cert instead of writing the Caddyfile (the orama-node
|
||||
// service can't, ProtectSystem=strict). These pin the validation logic.
|
||||
|
||||
func TestIsSingleLabelSubdomain(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, base string
|
||||
want bool
|
||||
}{
|
||||
{"cdn-a1b2c3d4e5f6.orama-devnet.network", "orama-devnet.network", true},
|
||||
{"turn.ns-anchat-test.orama-devnet.network", "orama-devnet.network", false}, // multi-label
|
||||
{"orama-devnet.network", "orama-devnet.network", false}, // empty label
|
||||
{"cdn-x.other.network", "orama-devnet.network", false}, // wrong base
|
||||
{"cdn-x.example.com", "example.com", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isSingleLabelSubdomain(c.host, c.base); got != c.want {
|
||||
t.Errorf("isSingleLabelSubdomain(%q, %q) = %v; want %v", c.host, c.base, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaddyWildcardCertPaths_shape(t *testing.T) {
|
||||
crt, key := caddyWildcardCertPaths("orama-devnet.network")
|
||||
wantCrt := "/var/lib/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.orama-devnet.network/wildcard_.orama-devnet.network.crt"
|
||||
if crt != wantCrt {
|
||||
t.Errorf("cert path = %q; want %q", crt, wantCrt)
|
||||
}
|
||||
if !strings.HasSuffix(key, "wildcard_.orama-devnet.network.key") {
|
||||
t.Errorf("key path = %q; want a wildcard .key", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStealthCert_rejectsMultiLabelHost(t *testing.T) {
|
||||
s := testSpawner(t)
|
||||
// A host that needs *.ns-x.<base> (multi-label) is NOT covered by the
|
||||
// *.<base> wildcard — must error rather than present a mismatched cert.
|
||||
_, _, err := s.resolveStealthCert("turn.ns-x.orama-devnet.network", "orama-devnet.network")
|
||||
if err == nil {
|
||||
t.Fatal("multi-label host must be rejected (wildcard wouldn't cover it)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "single-label") {
|
||||
t.Errorf("error should explain the single-label requirement; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStealthCert_missingWildcardErrors(t *testing.T) {
|
||||
s := testSpawner(t)
|
||||
// Valid single-label host but the wildcard cert almost certainly does not
|
||||
// exist at the absolute Caddy storage path during tests → hard error
|
||||
// naming the path, never a self-signed fallback.
|
||||
_, _, err := s.resolveStealthCert("cdn-deadbeef0000.test-nonexistent-base.invalid", "test-nonexistent-base.invalid")
|
||||
if err == nil {
|
||||
t.Fatal("missing wildcard cert must hard-fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wildcard") {
|
||||
t.Errorf("error should reference the missing wildcard cert; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStealthCert_emptyBaseErrors(t *testing.T) {
|
||||
s := testSpawner(t)
|
||||
if _, _, err := s.resolveStealthCert("cdn-x.example.com", ""); err == nil {
|
||||
t.Fatal("empty base domain must error")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user