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.<dnsZone> 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.<dnsZone>, 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).
This commit is contained in:
anonpenguin23 2026-06-15 23:08:39 +03:00
parent 3779ba9502
commit e3b2f08a0a
7 changed files with 97 additions and 0 deletions

View File

@ -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

View File

@ -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"`
}

View File

@ -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>, 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

View File

@ -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.<dnsZone> 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)
}
}

View File

@ -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.<dnsZone> 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;

View File

@ -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.<dnsZone>"), 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;

View File

@ -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,