From e3b2f08a0a5c0615c6e593987cdcb6ff8ca09eaf Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Mon, 15 Jun 2026 23:08:39 +0300 Subject: [PATCH] fix(gateway): plumb ntfy_base_url into gateway config so push fan-out activates (#858) The ntfy fan-out (publish each push to every active push node so a round-robin-DNS-pinned subscriber receives it) was coded but INERT: the gateway's cfg.NtfyBaseURL was never populated, so the fan-out resolver was never built and pushes went single-host (the ~87% loss the bug describes). The orchestrator already derives https://push. for the ntfy server + Caddy reverse-proxy but never put it in node.yaml's http_gateway. Same regression class as the v0.122.42 secrets_encryption_key fix (consumer landed; template + parse field + node->gateway mapping were missed). Plumb it through all four layers: render it under http_gateway (derived as push., matching the ntfy host), parse it in HTTPGatewayConfig, map it onto gateway.Config. Rolling-upgrade safe: Phase 4 regen runs under the new binary (post-swap), so an old binary never reads a node.yaml with the new field. DecodeStrict regression guard added (mirrors secrets_encryption_key). --- core/pkg/config/decode_test.go | 23 ++++++++++++ core/pkg/config/gateway_config.go | 10 +++++ core/pkg/environments/production/config.go | 12 ++++++ .../production/config_ntfy_test.go | 37 +++++++++++++++++++ core/pkg/environments/templates/node.yaml | 8 ++++ core/pkg/environments/templates/render.go | 6 +++ core/pkg/node/gateway.go | 1 + 7 files changed, 97 insertions(+) create mode 100644 core/pkg/environments/production/config_ntfy_test.go diff --git a/core/pkg/config/decode_test.go b/core/pkg/config/decode_test.go index 018b089..dcf79fa 100644 --- a/core/pkg/config/decode_test.go +++ b/core/pkg/config/decode_test.go @@ -235,6 +235,29 @@ http_gateway: } } +// TestDecodeStrict_ntfyBaseURL guards the same v0.122.42-class boot crash for +// the bugboard #858 ntfy fan-out: Phase 4 now emits `ntfy_base_url` under +// http_gateway, so HTTPGatewayConfig MUST carry a matching field or +// KnownFields(true) rejects the whole node.yaml and orama-node crash-loops. +// If someone deletes the parse field, the render tests still pass but +// production crash-loops — this guard catches that. +func TestDecodeStrict_ntfyBaseURL(t *testing.T) { + yamlInput := ` +node: + id: "test-node" +http_gateway: + enabled: true + ntfy_base_url: "https://push.dbrs.space" +` + var cfg Config + if err := DecodeStrict(strings.NewReader(yamlInput), &cfg); err != nil { + t.Fatalf("node.yaml with ntfy_base_url must parse (bugboard #858): %v", err) + } + if cfg.HTTPGateway.NtfyBaseURL != "https://push.dbrs.space" { + t.Errorf("NtfyBaseURL = %q, want https://push.dbrs.space", cfg.HTTPGateway.NtfyBaseURL) + } +} + // TestDecodeStrict_sniRouterBlock guards against a recurrence of the // v0.122.42-class boot crash for the feat-124 stealth SNI router: Phase 4 // always emits a top-level `sni_router:` block into node.yaml, so the root diff --git a/core/pkg/config/gateway_config.go b/core/pkg/config/gateway_config.go index 72ecf56..f21eb00 100644 --- a/core/pkg/config/gateway_config.go +++ b/core/pkg/config/gateway_config.go @@ -30,6 +30,16 @@ type HTTPGatewayConfig struct { // and the node→gateway mapping were missed). SecretsEncryptionKey string `yaml:"secrets_encryption_key"` + // NtfyBaseURL is the shared self-hosted ntfy base URL (e.g. + // "https://push.orama-devnet.network"). When set, the push ntfy provider + // fans each publish out to every active push node so a subscriber pinned + // to any instance by round-robin DNS receives it (bugboard #858). Rendered + // under http_gateway by Phase 4 config generation as "https://push."+dnsZone + // — matching the ntfy server + Caddy reverse-proxy host. Empty → no fan-out + // (single-host delivery, the ~87% loss the fix exists to remove). MUST exist + // here or the node→gateway mapping cannot populate gateway.Config.NtfyBaseURL. + NtfyBaseURL string `yaml:"ntfy_base_url"` + // WebRTC configuration (optional, enabled per-namespace) WebRTC WebRTCConfig `yaml:"webrtc"` } diff --git a/core/pkg/environments/production/config.go b/core/pkg/environments/production/config.go index ae27b2a..377eb7a 100644 --- a/core/pkg/environments/production/config.go +++ b/core/pkg/environments/production/config.go @@ -220,6 +220,18 @@ func (cg *ConfigGenerator) GenerateNodeConfig(peerAddresses []string, vpsIP stri data.SecretsEncryptionKey = strings.TrimSpace(string(keyBytes)) } + // Shared self-hosted ntfy base URL (bugboard #858). Derive it the SAME way + // the orchestrator derives the ntfy server + Caddy reverse-proxy host + // (push., dnsZone = baseDomain or the node domain), so the gateway's + // NtfyBaseURL matches and the push provider fans each publish out to every + // active push node instead of single-host delivery. Without this the fan-out + // code is inert and ~87% of publishes never reach a pinned subscriber. + if dnsZone := baseDomain; dnsZone != "" { + data.NtfyBaseURL = "https://push." + dnsZone + } else if domain != "" { + data.NtfyBaseURL = "https://push." + domain + } + // WebRTC/TURN config (feat-124 #913). The TURN secret lives in the secrets // dir so it survives Phase4 config regeneration; turn_domain/sfu_port/enabled // are operator-set values that only exist in the previous node.yaml, so we diff --git a/core/pkg/environments/production/config_ntfy_test.go b/core/pkg/environments/production/config_ntfy_test.go new file mode 100644 index 0000000..d056347 --- /dev/null +++ b/core/pkg/environments/production/config_ntfy_test.go @@ -0,0 +1,37 @@ +package production + +import ( + "strings" + "testing" +) + +// Bugboard #858 — the ntfy fan-out only activates when the gateway config +// carries NtfyBaseURL. The fan-out consumer (dependencies.go) shipped, but the +// template + parse field + node→gateway mapping were missed, so cfg.NtfyBaseURL +// stayed empty and every publish went single-host (~87% loss). These pin that +// the generated node.yaml now renders ntfy_base_url under http_gateway, derived +// as push. to match the ntfy server + Caddy reverse-proxy host. + +func TestGenerateNodeConfig_rendersNtfyBaseURL_fromBaseDomain(t *testing.T) { + cg := NewConfigGenerator(t.TempDir()) + out, err := cg.GenerateNodeConfig(nil, "10.0.0.5", "", "node-1.dbrs.space", "dbrs.space", false) + if err != nil { + t.Fatalf("GenerateNodeConfig failed: %v", err) + } + if !strings.Contains(out, `ntfy_base_url: "https://push.dbrs.space"`) { + t.Errorf("node.yaml missing ntfy_base_url derived from base domain\n---\n%s", out) + } +} + +func TestGenerateNodeConfig_ntfyBaseURL_fallsBackToDomain(t *testing.T) { + // No base domain → derive from the node domain (matches the orchestrator's + // dnsZone := baseDomain; if empty -> domain). + cg := NewConfigGenerator(t.TempDir()) + out, err := cg.GenerateNodeConfig(nil, "10.0.0.5", "", "anchor.example.net", "", false) + if err != nil { + t.Fatalf("GenerateNodeConfig failed: %v", err) + } + if !strings.Contains(out, `ntfy_base_url: "https://push.anchor.example.net"`) { + t.Errorf("node.yaml missing ntfy_base_url fallback to node domain\n---\n%s", out) + } +} diff --git a/core/pkg/environments/templates/node.yaml b/core/pkg/environments/templates/node.yaml index 552e766..ee4cc5b 100644 --- a/core/pkg/environments/templates/node.yaml +++ b/core/pkg/environments/templates/node.yaml @@ -102,6 +102,14 @@ http_gateway: # (bugboard #837). Sourced from ~/.orama/secrets/secrets-encryption-key. secrets_encryption_key: "{{.SecretsEncryptionKey}}" {{- end}} +{{- if .NtfyBaseURL}} + # Shared self-hosted ntfy base URL (bugboard #858). Same push. host + # the ntfy server + Caddy reverse-proxy use, so the gateway's push provider + # fans each publish out to every active push node (each node runs an + # independent ntfy with no shared store; without fan-out a subscriber pinned + # to a different instance than the publish lands on misses it ~87% of the time). + ntfy_base_url: "{{.NtfyBaseURL}}" +{{- end}} {{- if .TURNSecret}} # WebRTC/TURN config (feat-124 #913). turn_secret is sourced from # ~/.orama/secrets/turn-secret so it survives config regeneration; diff --git a/core/pkg/environments/templates/render.go b/core/pkg/environments/templates/render.go index 222f858..32de943 100644 --- a/core/pkg/environments/templates/render.go +++ b/core/pkg/environments/templates/render.go @@ -56,6 +56,12 @@ type NodeConfigData struct { // stays disabled until the key is configured). SecretsEncryptionKey string + // NtfyBaseURL is the shared self-hosted ntfy base URL (e.g. + // "https://push."), rendered under http_gateway as ntfy_base_url. + // When set, the gateway's push provider fans each ntfy publish out to every + // active push node (bugboard #858). Empty → omitted (single-host delivery). + NtfyBaseURL string + // WebRTC/TURN configuration, rendered under http_gateway.webrtc when // WebRTCEnabled is true (feat-124 #913). TURNSecret is sourced from // ~/.orama/secrets/turn-secret so it survives Phase4 config regeneration; diff --git a/core/pkg/node/gateway.go b/core/pkg/node/gateway.go index ab5689b..69bfece 100644 --- a/core/pkg/node/gateway.go +++ b/core/pkg/node/gateway.go @@ -85,6 +85,7 @@ func (n *Node) startHTTPGateway(ctx context.Context) error { ClusterSecret: clusterSecret, APIKeyHMACSecret: apiKeyHMACSecret, SecretsEncryptionKey: secretsEncryptionKey, + NtfyBaseURL: n.config.HTTPGateway.NtfyBaseURL, WebRTCEnabled: n.config.HTTPGateway.WebRTC.Enabled, SFUPort: n.config.HTTPGateway.WebRTC.SFUPort, TURNDomain: n.config.HTTPGateway.WebRTC.TURNDomain,