orama/core/pkg/sniproxy/discoverer.go
anonpenguin23 b9d5f542e1 feat(gateway): implement stealth TURN discovery and configuration
- Add `turn_stealth_domain` to gateway config for stealth TURN support
- Introduce `turn_discovery` in `sni-router` to auto-discover per-namespace routes
- Add database migration to enable stealth TURN per namespace
- Document ephemeral state API in `SERVERLESS.md`
2026-06-11 07:04:50 +03:00

130 lines
4.1 KiB
Go

package sniproxy
import (
"strings"
"time"
"go.uber.org/zap"
)
// discoveryWarnInterval rate-limits the "discovery scan failed" warning so a
// persistently-unreadable namespaces directory cannot flood the journal.
const discoveryWarnInterval = 5 * time.Minute
// StaticRoutes returns the operator-set routes parsed from the SNI router's own
// config file plus the fallback backend. The discoverer merges these with the
// auto-discovered TURN routes; static routes win on an SNI conflict.
type StaticRoutes func() (routes []Route, fallback Backend, err error)
// TURNRouteDiscoverer periodically rescans the namespaces directory for
// per-namespace TURNS listeners, merges the discovered routes with the static
// routes from the config file (static wins on conflict), and atomically
// installs the result on the Router.
//
// A transient failure (unreadable namespaces dir, or a bad static-config read)
// logs a rate-limited warning and KEEPS the previously-installed routes — a
// filesystem hiccup must never blackhole live :443 traffic.
type TURNRouteDiscoverer struct {
cfg TURNDiscoveryConfig
static StaticRoutes
router *Router
logger *zap.Logger
// lastWarn is only touched by the Run goroutine after the synchronous
// startup Apply, so it needs no lock.
lastWarn time.Time
}
// NewTURNRouteDiscoverer constructs a discoverer. static reads the operator's
// config-file routes + fallback; router receives the merged Replace calls.
func NewTURNRouteDiscoverer(cfg TURNDiscoveryConfig, static StaticRoutes, router *Router, logger *zap.Logger) *TURNRouteDiscoverer {
if logger == nil {
logger = zap.NewNop()
}
return &TURNRouteDiscoverer{cfg: cfg, static: static, router: router, logger: logger}
}
// Apply performs one scan+merge and installs the result atomically. On any
// transient error it returns the error and leaves the Router untouched so the
// caller can decide whether to fail startup (Apply) or keep stale routes (Run).
func (d *TURNRouteDiscoverer) Apply() error {
staticRoutes, fallback, err := d.static()
if err != nil {
return err
}
discovered, err := DiscoverTURNRoutes(d.cfg, d.logger)
if err != nil {
return err
}
merged := mergeRoutes(staticRoutes, discovered)
d.router.Replace(merged, fallback)
return nil
}
// Run scans immediately, then every rescan interval until stop is closed. A
// failed scan keeps the current routes and logs a rate-limited warning.
func (d *TURNRouteDiscoverer) Run(stop <-chan struct{}) {
if err := d.Apply(); err != nil {
d.warn("initial TURN route discovery failed; serving config-file routes only", err)
}
interval := d.cfg.RescanInterval
if interval <= 0 {
interval = DefaultDiscoveryRescanInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
if err := d.Apply(); err != nil {
d.warn("TURN route discovery failed; keeping current routes", err)
continue
}
}
}
}
// warn logs at most once per discoveryWarnInterval to avoid journal flooding
// when the namespaces directory is persistently unreadable.
func (d *TURNRouteDiscoverer) warn(msg string, err error) {
now := time.Now()
if now.Sub(d.lastWarn) < discoveryWarnInterval {
return
}
d.lastWarn = now
d.logger.Warn(msg,
zap.String("namespaces_dir", d.cfg.NamespacesDir),
zap.Error(err))
}
// mergeRoutes combines static and discovered routes with static taking
// precedence on an SNI-match conflict. Static routes keep their original order
// and precede discovered ones, matching Router.Pick's first-match semantics.
func mergeRoutes(static, discovered []Route) []Route {
seen := make(map[string]struct{}, len(static))
merged := make([]Route, 0, len(static)+len(discovered))
for _, r := range static {
seen[matchKey(r.Match)] = struct{}{}
merged = append(merged, r)
}
for _, r := range discovered {
if _, conflict := seen[matchKey(r.Match)]; conflict {
continue // static wins
}
merged = append(merged, r)
}
return merged
}
// matchKey normalizes an SNI match for conflict comparison (matching is
// case-insensitive, mirroring Router.Pick / matchSNI).
func matchKey(match string) string {
return strings.ToLower(match)
}