Wire PULSE_RELAY_ENABLED and PULSE_RELAY_SERVER as real env overrides

These two env vars were documented as relay overrides in v6 docs since
March 18 (CONFIGURATION.md, RELAY.md, and the frontend-served doc copy)
but no code ever read them. Operators trying to bootstrap relay headlessly
saw no effect.

Implement them rather than remove the documentation. Headless and
container deployments now have a real path to enable relay and point it
at a private endpoint without going through Settings → Relay.

internal/relay/config_env.go:
  - ApplyEnvOverrides(*Config) mutates relay.Config in place.
  - PULSE_RELAY_ENABLED accepts true/false/yes/no/1/0/on/off (case-
    insensitive). Unrecognized values log a warning and leave the file
    value untouched — important so "unset" reads differently from
    "explicit false."
  - PULSE_RELAY_SERVER goes through the existing validateRelayServerURL
    check; invalid URLs log a warning and fall through.

internal/config/persistence_relay.go:
  LoadRelayConfig calls ApplyEnvOverrides after the file load and after
  the default-fallback when relay.enc is absent, so the env override
  applies on every load.

Tests cover unset / true / false / garbage-bool / valid-URL / invalid-URL
/ both-together / nil-config paths in the relay package, plus two
end-to-end tests in internal/config that prove the override flows through
LoadRelayConfig against a real persisted file and against the
missing-file default branch.

Restore the env-var docs with the correct default URL (the full
wss://relay.pulserelay.pro/ws/instance, not the bare hostname the
original aspirational table claimed) and add an explicit precedence note:
saving from the UI after an env override persists the env-effective state
to disk, so clearing the env alone does not revert.

Add internal/relay/config_env_test.go to the relay-runtime registry's
desktop-relay-runtime exact_files so the new code surface is proof-tracked.
Update the matching pin in subsystem_lookup_test.py. Extend the
relay-runtime contract Extension Point 3 to document the override
semantics LoadRelayConfig must satisfy.
This commit is contained in:
rcourtman
2026-05-12 11:18:31 +01:00
parent 7cc90a8969
commit b69c8c8007
10 changed files with 296 additions and 11 deletions
+16 -4
View File
@@ -502,10 +502,22 @@ The relay protocol provides end-to-end encrypted remote access foundations for P
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client.
Relay has no environment-variable overrides. Enable/disable and the
server URL are persisted in `relay.enc` and configured from
**Settings → Relay**. The relay server URL defaults to
`wss://relay.pulserelay.pro/ws/instance`.
### Environment Overrides
For headless / container deployments that need to bootstrap relay without
going through the UI, two environment variables override the persisted
`relay.enc` values at load time:
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay (`true`/`false`/`yes`/`no`/`1`/`0`). Unset or unrecognized values leave the file value untouched. | *(unset)* |
| `PULSE_RELAY_SERVER` | Override relay server URL. Must be a valid `ws://` or `wss://` URL with no userinfo, query, or fragment. Invalid values are logged and ignored. | `wss://relay.pulserelay.pro/ws/instance` |
Precedence: env vars beat the file. If you set `PULSE_RELAY_ENABLED=true`,
saving the relay form in **Settings → Relay** will then persist the
env-effective state to disk, so removing the env var later does not
automatically revert relay back to its previous file-stored state — clear
relay in the UI as well if you want to fully disable it.
### Security
+9 -2
View File
@@ -53,10 +53,17 @@ Relay was designed with a zero-trust model:
### Environment Variables
For headless / container deployments, two env vars override the persisted
`relay.enc` values at load time. Unset leaves the file value untouched.
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay | `false` |
| `PULSE_RELAY_SERVER` | Override relay server URL | `relay.pulserelay.pro` |
| `PULSE_RELAY_ENABLED` | Enable/disable relay (`true`/`false`/`yes`/`no`/`1`/`0`). Unrecognized values are ignored. | *(unset)* |
| `PULSE_RELAY_SERVER` | Override relay server URL. Must be `ws://` or `wss://`. Invalid values are logged and ignored. | `wss://relay.pulserelay.pro/ws/instance` |
Env vars take precedence over the file at load. Saving from the UI after an
env override is active persists the env-effective state to disk, so clearing
the env var alone will not revert the change — disable in the UI too.
### Storage
@@ -4561,6 +4561,7 @@
"test_prefixes": [],
"exact_files": [
"internal/relay/client_test.go",
"internal/relay/config_env_test.go",
"internal/relay/encryption_test.go"
]
},
@@ -40,6 +40,16 @@ for Pulse instance bridging.
1. Add or change desktop relay reconnect, registration, drain, proxy-stream, or encrypted channel behavior through `internal/relay/`
2. Add or change relay control payload schemas, including mobile-visible push notification metadata, through `internal/relay/protocol.go`
3. Add or change persisted relay enablement, server URL, or reconnect-safe default loading through `internal/config/persistence_relay.go`
`LoadRelayConfig` must apply environment-variable overrides
(`PULSE_RELAY_ENABLED`, `PULSE_RELAY_SERVER`) on top of the file-loaded
or default-fallback `relay.Config` via `relay.ApplyEnvOverrides`. The
env overrides must distinguish unset / empty / unparseable values
(file or default wins) from explicit `true`/`false`/valid-URL values
(env wins), and must reject invalid `PULSE_RELAY_SERVER` URLs through
the canonical `validateRelayServerURL` check rather than silently
accepting a malformed override. Saving the resulting `relay.Config`
from the UI is allowed to persist the env-effective state to disk;
the override is not stripped before save.
4. Add or change the backend-owned mobile relay capability inventory and compatibility scope mapping through `internal/api/relay_mobile_capability.go`
5. Add or change mobile relay reconnect, drain, channel, encryption, proxy, or identity behavior through `pulse-mobile:src/relay/`
6. Keep desktop and mobile relay changes aligned with the governed server relay surfaces represented by the L7 lane evidence
+16 -4
View File
@@ -502,10 +502,22 @@ The relay protocol provides end-to-end encrypted remote access foundations for P
2. Toggle relay **On**.
3. Use the **QR Code** or **Deep Link** to pair a supported Pulse Mobile client.
Relay has no environment-variable overrides. Enable/disable and the
server URL are persisted in `relay.enc` and configured from
**Settings → Relay**. The relay server URL defaults to
`wss://relay.pulserelay.pro/ws/instance`.
### Environment Overrides
For headless / container deployments that need to bootstrap relay without
going through the UI, two environment variables override the persisted
`relay.enc` values at load time:
| Variable | Description | Default |
|---|---|---|
| `PULSE_RELAY_ENABLED` | Enable/disable relay (`true`/`false`/`yes`/`no`/`1`/`0`). Unset or unrecognized values leave the file value untouched. | *(unset)* |
| `PULSE_RELAY_SERVER` | Override relay server URL. Must be a valid `ws://` or `wss://` URL with no userinfo, query, or fragment. Invalid values are logged and ignored. | `wss://relay.pulserelay.pro/ws/instance` |
Precedence: env vars beat the file. If you set `PULSE_RELAY_ENABLED=true`,
saving the relay form in **Settings → Relay** will then persist the
env-effective state to disk, so removing the env var later does not
automatically revert relay back to its previous file-stored state — clear
relay in the UI as well if you want to fully disable it.
### Security
+6 -1
View File
@@ -20,14 +20,19 @@ func (c *ConfigPersistence) SaveRelayConfig(cfg relay.Config) error {
}
// LoadRelayConfig retrieves the persisted relay settings. Returns default config if none exists.
// PULSE_RELAY_ENABLED / PULSE_RELAY_SERVER env vars override the file values
// after load — see relay.ApplyEnvOverrides for the precedence rules.
func (c *ConfigPersistence) LoadRelayConfig() (*relay.Config, error) {
cfg := relay.DefaultConfig()
if err := loadJSON(c, c.relayFile, true, cfg); err != nil {
if errors.Is(err, fs.ErrNotExist) || os.IsNotExist(err) {
return relay.DefaultConfig(), nil
cfg = relay.DefaultConfig()
relay.ApplyEnvOverrides(cfg)
return cfg, nil
}
return nil, err
}
relay.ApplyEnvOverrides(cfg)
return cfg, nil
}
+43
View File
@@ -72,3 +72,46 @@ func TestConfigPersistenceLoadRelayConfigMigratesPlaintextFile(t *testing.T) {
t.Fatal("expected plaintext relay config to be rewritten encrypted")
}
}
func TestConfigPersistenceLoadRelayConfigAppliesEnvOverrideOverFile(t *testing.T) {
tempDir := t.TempDir()
cp := NewConfigPersistence(tempDir)
// File says disabled with the default URL.
if err := cp.SaveRelayConfig(*relay.DefaultConfig()); err != nil {
t.Fatalf("SaveRelayConfig() error = %v", err)
}
// Env says enable + redirect to a private endpoint.
t.Setenv(relay.EnvRelayEnabled, "true")
t.Setenv(relay.EnvRelayServerURL, "wss://relay.internal.example/ws/instance")
cfg, err := cp.LoadRelayConfig()
if err != nil {
t.Fatalf("LoadRelayConfig() error = %v", err)
}
if !cfg.Enabled {
t.Fatalf("LoadRelayConfig() enabled = false, want true (env override should beat file)")
}
if cfg.ServerURL != "wss://relay.internal.example/ws/instance" {
t.Fatalf("LoadRelayConfig() server_url = %q, want env override", cfg.ServerURL)
}
}
func TestConfigPersistenceLoadRelayConfigAppliesEnvOverrideWithNoFile(t *testing.T) {
cp := NewConfigPersistence(t.TempDir())
t.Setenv(relay.EnvRelayEnabled, "yes")
t.Setenv(relay.EnvRelayServerURL, "wss://relay.internal.example/ws/instance")
cfg, err := cp.LoadRelayConfig()
if err != nil {
t.Fatalf("LoadRelayConfig() error = %v", err)
}
if !cfg.Enabled {
t.Fatalf("LoadRelayConfig() enabled = false, want true (env override on default-when-missing)")
}
if cfg.ServerURL != "wss://relay.internal.example/ws/instance" {
t.Fatalf("LoadRelayConfig() server_url = %q, want env override", cfg.ServerURL)
}
}
+73
View File
@@ -0,0 +1,73 @@
package relay
import (
"os"
"strings"
"github.com/rs/zerolog/log"
)
// Env vars for headless / container deployments that need to bootstrap relay
// without going through Settings → Relay. They override the persisted file
// config; if you set them you accept that UI changes will be re-overridden on
// next start.
const (
EnvRelayEnabled = "PULSE_RELAY_ENABLED"
EnvRelayServerURL = "PULSE_RELAY_SERVER"
)
// ApplyEnvOverrides mutates cfg in place to reflect PULSE_RELAY_* environment
// overrides. Unset / empty / unparseable env vars do not override; the file
// (or default) value remains. Invalid server URLs are logged and ignored —
// the override silently falls back rather than leaving relay wedged on a
// malformed endpoint.
func ApplyEnvOverrides(cfg *Config) {
if cfg == nil {
return
}
if rawEnabled := strings.TrimSpace(os.Getenv(EnvRelayEnabled)); rawEnabled != "" {
if parsed, ok := parseEnvBool(rawEnabled); ok {
cfg.Enabled = parsed
log.Info().
Str("env_var", EnvRelayEnabled).
Bool("enabled", parsed).
Msg("relay configuration overridden by environment variable")
} else {
log.Warn().
Str("env_var", EnvRelayEnabled).
Str("value", rawEnabled).
Msg("relay env override is not a recognized boolean; ignoring")
}
}
if rawURL := strings.TrimSpace(os.Getenv(EnvRelayServerURL)); rawURL != "" {
if err := validateRelayServerURL(rawURL); err != nil {
log.Warn().
Str("env_var", EnvRelayServerURL).
Str("value", rawURL).
Err(err).
Msg("relay env override is not a valid ws/wss URL; keeping persisted value")
} else {
cfg.ServerURL = rawURL
log.Info().
Str("env_var", EnvRelayServerURL).
Str("server_url", rawURL).
Msg("relay configuration overridden by environment variable")
}
}
}
// parseEnvBool returns (value, ok). ok=false means the input was not a
// recognizable boolean and the caller should leave the config field alone.
// Distinct from utils.ParseBool which silently coerces everything unknown to
// false; for env overrides we need to tell "unset" from "explicit false".
func parseEnvBool(rawValue string) (value bool, ok bool) {
switch strings.ToLower(strings.TrimSpace(rawValue)) {
case "1", "true", "yes", "y", "on":
return true, true
case "0", "false", "no", "n", "off":
return false, true
}
return false, false
}
+121
View File
@@ -0,0 +1,121 @@
package relay
import "testing"
func TestApplyEnvOverridesUnsetLeavesConfigAlone(t *testing.T) {
t.Setenv(EnvRelayEnabled, "")
t.Setenv(EnvRelayServerURL, "")
cfg := &Config{Enabled: true, ServerURL: "wss://file.example/ws/instance"}
ApplyEnvOverrides(cfg)
if !cfg.Enabled {
t.Fatalf("Enabled = false, want true (file value preserved when env unset)")
}
if cfg.ServerURL != "wss://file.example/ws/instance" {
t.Fatalf("ServerURL = %q, want file value preserved", cfg.ServerURL)
}
}
func TestApplyEnvOverridesEnabledTrueOverridesFile(t *testing.T) {
t.Setenv(EnvRelayEnabled, "true")
t.Setenv(EnvRelayServerURL, "")
cfg := &Config{Enabled: false, ServerURL: DefaultServerURL}
ApplyEnvOverrides(cfg)
if !cfg.Enabled {
t.Fatalf("Enabled = false, want true (env override true)")
}
}
func TestApplyEnvOverridesEnabledFalseOverridesFile(t *testing.T) {
t.Setenv(EnvRelayEnabled, "false")
t.Setenv(EnvRelayServerURL, "")
cfg := &Config{Enabled: true, ServerURL: DefaultServerURL}
ApplyEnvOverrides(cfg)
if cfg.Enabled {
t.Fatalf("Enabled = true, want false (env override false should disable a file-enabled relay)")
}
}
func TestApplyEnvOverridesGarbageBoolIgnored(t *testing.T) {
t.Setenv(EnvRelayEnabled, "maybe")
t.Setenv(EnvRelayServerURL, "")
cfg := &Config{Enabled: true, ServerURL: DefaultServerURL}
ApplyEnvOverrides(cfg)
if !cfg.Enabled {
t.Fatalf("Enabled = false, want true (garbage bool should not override)")
}
}
func TestApplyEnvOverridesValidServerURLOverridesFile(t *testing.T) {
t.Setenv(EnvRelayEnabled, "")
t.Setenv(EnvRelayServerURL, "wss://relay.test.example/ws/instance")
cfg := &Config{Enabled: true, ServerURL: DefaultServerURL}
ApplyEnvOverrides(cfg)
if cfg.ServerURL != "wss://relay.test.example/ws/instance" {
t.Fatalf("ServerURL = %q, want env override", cfg.ServerURL)
}
}
func TestApplyEnvOverridesInvalidServerURLKeepsFile(t *testing.T) {
t.Setenv(EnvRelayEnabled, "")
t.Setenv(EnvRelayServerURL, "http://wrong-scheme.example/")
cfg := &Config{Enabled: true, ServerURL: "wss://file.example/ws/instance"}
ApplyEnvOverrides(cfg)
if cfg.ServerURL != "wss://file.example/ws/instance" {
t.Fatalf("ServerURL = %q, want file value preserved (invalid env URL should not override)", cfg.ServerURL)
}
}
func TestApplyEnvOverridesBothApplyTogether(t *testing.T) {
t.Setenv(EnvRelayEnabled, "yes")
t.Setenv(EnvRelayServerURL, "wss://relay.test.example/ws/instance")
cfg := &Config{Enabled: false, ServerURL: DefaultServerURL}
ApplyEnvOverrides(cfg)
if !cfg.Enabled || cfg.ServerURL != "wss://relay.test.example/ws/instance" {
t.Fatalf("ApplyEnvOverrides did not apply both overrides: %+v", cfg)
}
}
func TestApplyEnvOverridesNilConfigSafe(t *testing.T) {
t.Setenv(EnvRelayEnabled, "true")
t.Setenv(EnvRelayServerURL, "wss://relay.test.example/ws/instance")
ApplyEnvOverrides(nil) // must not panic
}
func TestParseEnvBoolRecognizedValues(t *testing.T) {
truthy := []string{"1", "true", "TRUE", "True", "yes", "y", "on", " on "}
for _, v := range truthy {
got, ok := parseEnvBool(v)
if !ok || !got {
t.Errorf("parseEnvBool(%q) = (%v, %v), want (true, true)", v, got, ok)
}
}
falsy := []string{"0", "false", "FALSE", "no", "n", "off"}
for _, v := range falsy {
got, ok := parseEnvBool(v)
if !ok || got {
t.Errorf("parseEnvBool(%q) = (%v, %v), want (false, true)", v, got, ok)
}
}
unrecognized := []string{"", " ", "maybe", "2", "enable"}
for _, v := range unrecognized {
_, ok := parseEnvBool(v)
if ok {
t.Errorf("parseEnvBool(%q) reported ok=true, want false", v)
}
}
}
@@ -2079,6 +2079,7 @@ class SubsystemLookupTest(unittest.TestCase):
match["verification_requirement"]["exact_files"],
[
"internal/relay/client_test.go",
"internal/relay/config_env_test.go",
"internal/relay/encryption_test.go",
],
)