mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
+16
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user