Skip to content

Commit f16b622

Browse files
committed
fix(relay-server): stop the report from answering a question it was not asked
Three findings from review, all cases where the report was confident about something it had not actually checked. `--env-file` was documented as showing what a deployment will run, but it resolves the file against the relay binary's defaults. Compose supplies its own first: a file carrying only PORTAL_URL and UDP_ENABLED=true reported `udp-transport blocked` while `docker compose up` would give it MIN_PORT=40000 and enable it. Reimplementing Compose's defaults here would build a second configuration engine, so the claim is narrowed instead. The accurate check needs no new code — running the subcommand inside the container lets Compose build the environment first: docker compose run --rm -T portal config That is now what .env.example, the configuration page and the command's own usage recommend, and a file-scoped report says in its header which of the two questions it answered. `discoveryFeature` inspected only the parsed hostname, so PORTAL_URL=http://relay.example.com reported enabled and portal.NewServer rejected the same value seconds later — the exact divergence this feature exists to remove. It now calls utils.NormalizeRelayURL and utils.NormalizeRelayURLs, the same normalization the server applies, rather than holding a second opinion about it. Bootstraps are counted after normalization for the same reason. loadEnvFile silently skipped any line without an `=`. `DISCOVERY true` vanished and discovery reported its default with nothing to explain why, which is the silent misconfiguration this command was written to expose. Malformed lines now fail with file:line. Also keys dnsProviderCredential by acme's exported Type* constants rather than repeating the provider names, so acme stays the one place that decides what is supported and this map only adds the credential each one needs.
1 parent e78c232 commit f16b622

5 files changed

Lines changed: 165 additions & 20 deletions

File tree

.env.example

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33
# Copy to .env and edit. Every key here is read either by the relay binary or by
44
# Docker Compose; nothing else reads this file.
55
#
6-
# Check a configured file before starting anything. The report gives the
6+
# Check the configuration before starting anything. The report gives the
77
# effective value and source of every key, names any key that nothing reads, and
88
# says which features are off and what is missing:
99
#
10-
# docker compose run --rm -T portal config --env-file /dev/stdin < .env
10+
# docker compose run --rm -T portal config
1111
#
12-
# -T because the file arrives on stdin. Without it Compose asks for a TTY and
13-
# older versions fail with "the input device is not a TTY".
12+
# No --env-file: Compose has already built the container's environment from this
13+
# file plus its own defaults, so the report describes what `docker compose up`
14+
# will actually run. Passing --env-file instead reads the file against the relay
15+
# binary's defaults, which differ -- MIN_PORT is 0 there and 40000 under Compose
16+
# -- and would report features as blocked that the deployment enables.
17+
#
18+
# -T because `docker compose run` asks for a TTY otherwise, and older versions
19+
# fail with "the input device is not a TTY".
1420
#
1521
# API_PORT and SNI_PORT are deliberately absent. The bundled topology fixes them
1622
# at 4017 and 443 because the relay reaches its own API listener through its SNI

cmd/relay-server/config.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ func discoveryFeature(cfg relayServerConfig) feature {
100100
f.State, f.By = stateDisabled, "DISCOVERY=false"
101101
return f
102102
}
103+
// The same normalization portal.NewServer applies, not a second opinion
104+
// about it. Checking only the parsed hostname would report PORTAL_URL=http://…
105+
// as enabled and then have the server reject it seconds later, which is
106+
// exactly the divergence this report exists to remove.
107+
if _, err := utils.NormalizeRelayURL(cfg.PortalURL); err != nil {
108+
f.State, f.By = stateBlocked, "DISCOVERY=true"
109+
f.Missing = fmt.Sprintf("PORTAL_URL is not usable as a relay URL: %v", err)
110+
return f
111+
}
103112
host := portalURLHost(cfg.PortalURL)
104113
if host == "" || utils.IsLocalRelayHost(host) {
105114
f.State, f.By = stateBlocked, "DISCOVERY=true"
@@ -108,9 +117,15 @@ func discoveryFeature(cfg relayServerConfig) feature {
108117
host)
109118
return f
110119
}
120+
bootstraps, err := utils.NormalizeRelayURLs(utils.SplitCSV(cfg.Bootstraps)...)
121+
if err != nil {
122+
f.State, f.By = stateBlocked, "DISCOVERY=true"
123+
f.Missing = fmt.Sprintf("BOOTSTRAPS is not usable: %v", err)
124+
return f
125+
}
111126
f.State, f.By = stateEnabled, "DISCOVERY=true"
112127
f.Detail = fmt.Sprintf("host=%s bootstraps=%d wireguard_port=%d",
113-
host, len(utils.SplitCSV(cfg.Bootstraps)), cfg.WireGuardPort)
128+
host, len(bootstraps), cfg.WireGuardPort)
114129
return f
115130
}
116131

@@ -321,19 +336,25 @@ func loadEnvFile(path string) ([]envFileEntry, error) {
321336

322337
var entries []envFileEntry
323338
scanner := bufio.NewScanner(file)
339+
lineNo := 0
324340
for scanner.Scan() {
341+
lineNo++
325342
line := strings.TrimSpace(scanner.Text())
326343
if line == "" || strings.HasPrefix(line, "#") {
327344
continue
328345
}
329346
line = strings.TrimPrefix(line, "export ")
347+
// A line that is neither blank, a comment, nor an assignment is a
348+
// mistake, and skipping it would reproduce the silent misconfiguration
349+
// this command exists to expose: `DISCOVERY true` would simply vanish
350+
// and the feature would report its default with nothing to explain why.
330351
name, value, found := strings.Cut(line, "=")
331352
if !found {
332-
continue
353+
return nil, fmt.Errorf("%s:%d: not an assignment: %q", path, lineNo, line)
333354
}
334355
name = strings.TrimSpace(name)
335356
if name == "" {
336-
continue
357+
return nil, fmt.Errorf("%s:%d: assignment has no name: %q", path, lineNo, line)
337358
}
338359
// Compose does not expand values read from an env file, so neither do we.
339360
value = strings.TrimSpace(value)
@@ -410,7 +431,18 @@ func displayValue(name, value string) string {
410431
func writeConfigReport(w io.Writer, cfg relayServerConfig, entries []envFileEntry, source string) {
411432
relay := knownEnvNames()
412433

413-
fmt.Fprintf(w, "Portal relay configuration (%s)\n\n", source)
434+
fmt.Fprintf(w, "Portal relay configuration (%s)\n", source)
435+
// Say what was inspected, because the two modes answer different questions
436+
// and only one of them describes a Compose deployment. Reading a file in
437+
// isolation applies relay defaults to every key the file omits, while
438+
// Compose supplies its own first: a file with only PORTAL_URL reports
439+
// MIN_PORT=0 here, and `docker compose up` would run it with 40000.
440+
if len(entries) > 0 {
441+
fmt.Fprint(w, "Keys absent from this file take relay defaults. A Compose deployment\n"+
442+
"supplies its own first; for that environment run the command inside the\n"+
443+
"container instead: docker compose run --rm -T portal config\n")
444+
}
445+
fmt.Fprintln(w)
414446

415447
supplied := make(map[string]bool, len(entries))
416448
for _, entry := range entries {
@@ -723,7 +755,11 @@ func runConfigCommand(args []string) error {
723755
format string
724756
)
725757
fs := utils.NewFlagSet("relay-server config", printConfigUsage)
726-
utils.StringFlag(fs, &envFilePath, "env-file", "", "env file to inspect instead of the process environment")
758+
utils.StringFlag(fs, &envFilePath, "env-file", "",
759+
"read this file in place of the process environment, against relay defaults. "+
760+
"Compose supplies its own defaults on top of a file, so to see what a Compose "+
761+
"deployment will actually run, omit this flag and let Compose build the environment: "+
762+
"docker compose run --rm -T portal config")
727763
utils.StringFlag(fs, &format, "format", "text", "output format: text, env or names")
728764

729765
if err := utils.ParseFlagSet(fs, args, printConfigUsage); err != nil {
@@ -780,8 +816,9 @@ func printConfigUsage(w io.Writer) {
780816
"relay-server config [--env-file PATH] [--format text|env]",
781817
},
782818
[]string{
783-
"relay-server config",
784-
"relay-server config --env-file .env",
819+
"docker compose run --rm -T portal config # what Compose will run",
820+
"relay-server config # this process environment",
821+
"relay-server config --env-file .env # one file, against relay defaults",
785822
"relay-server config --format env > env.reference",
786823
},
787824
)

cmd/relay-server/config_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,82 @@ func TestACMEFeatureBlockedWithoutCredential(t *testing.T) {
163163
t.Fatalf("acme missing = %q, want it to name the credential", f.Missing)
164164
}
165165
}
166+
167+
// A line that is neither blank, a comment, nor an assignment is a typo, and
168+
// dropping it would recreate the silent misconfiguration this command exists to
169+
// expose: the feature would report its default with nothing to explain why.
170+
func TestLoadEnvFileRejectsMalformedLines(t *testing.T) {
171+
for name, line := range map[string]string{
172+
"missing separator": "DISCOVERY true",
173+
"empty name": "=true",
174+
} {
175+
t.Run(name, func(t *testing.T) {
176+
path := writeEnvFile(t, "PORTAL_URL=https://relay.example.com", line)
177+
178+
_, err := loadEnvFile(path)
179+
if err == nil {
180+
t.Fatalf("%q was accepted", line)
181+
}
182+
if !strings.Contains(err.Error(), ":2:") {
183+
t.Fatalf("error does not point at the line: %v", err)
184+
}
185+
})
186+
}
187+
}
188+
189+
func TestLoadEnvFileKeepsCommentsAndBlanks(t *testing.T) {
190+
path := writeEnvFile(t, "# a comment", "", " ", "export PORTAL_URL=https://relay.example.com")
191+
192+
entries, err := loadEnvFile(path)
193+
if err != nil {
194+
t.Fatalf("load env file: %v", err)
195+
}
196+
if len(entries) != 1 || entries[0].Name != "PORTAL_URL" {
197+
t.Fatalf("entries = %v, want only PORTAL_URL", entries)
198+
}
199+
}
200+
201+
// The report must not claim a feature works when the server will reject the
202+
// same value moments later. portal.NewServer normalizes PORTAL_URL through
203+
// utils.NormalizeRelayURL, which requires https.
204+
func TestDiscoveryBlockedForNonHTTPSPortalURL(t *testing.T) {
205+
path := writeEnvFile(t, "DISCOVERY=true", "PORTAL_URL=http://relay.example.com")
206+
cfg := resolveWithEnvFile(t, path)
207+
208+
f := discoveryFeature(cfg)
209+
if f.State != stateBlocked {
210+
t.Fatalf("discovery state = %q, want blocked for a non-https PORTAL_URL", f.State)
211+
}
212+
if !strings.Contains(f.Missing, "https") {
213+
t.Fatalf("missing = %q, want it to name the https requirement", f.Missing)
214+
}
215+
}
216+
217+
func TestDiscoveryBlockedForUnusableBootstraps(t *testing.T) {
218+
path := writeEnvFile(t,
219+
"DISCOVERY=true",
220+
"PORTAL_URL=https://relay.example.com",
221+
"BOOTSTRAPS=http://peer.example.com")
222+
cfg := resolveWithEnvFile(t, path)
223+
224+
f := discoveryFeature(cfg)
225+
if f.State != stateBlocked {
226+
t.Fatalf("discovery state = %q, want blocked for an unusable BOOTSTRAPS", f.State)
227+
}
228+
}
229+
230+
func TestDiscoveryEnabledCountsNormalizedBootstraps(t *testing.T) {
231+
path := writeEnvFile(t,
232+
"DISCOVERY=true",
233+
"PORTAL_URL=https://relay.example.com",
234+
"BOOTSTRAPS=https://a.example.com,https://b.example.com")
235+
cfg := resolveWithEnvFile(t, path)
236+
237+
f := discoveryFeature(cfg)
238+
if f.State != stateEnabled {
239+
t.Fatalf("discovery state = %q, want enabled", f.State)
240+
}
241+
if !strings.Contains(f.Detail, "bootstraps=2") {
242+
t.Fatalf("detail = %q, want bootstraps=2", f.Detail)
243+
}
244+
}

cmd/relay-server/envcatalog.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package main
22

3+
import "github.com/gosuda/portal-tunnel/v2/portal/acme"
4+
35
// The deployment .env is shared by the relay and by Docker Compose itself.
46
// Checking a key against the relay's own flags alone would report the
57
// Compose-level ones as unknown, so the keys owned elsewhere are catalogued
@@ -64,11 +66,16 @@ var pinnedByTopology = map[string]struct {
6466
// credential it requires. Providers whose credentials come from an ambient
6567
// chain (an instance role, application default credentials) map to an empty
6668
// list because there is nothing to require.
69+
//
70+
// The keys are acme's own exported constants rather than repeated strings, so
71+
// a provider added there cannot silently go unreported here: acme.NewDNSProvider
72+
// decides what is supported, and this map only adds the credential each one
73+
// needs, which is knowledge the report owns.
6774
var dnsProviderCredential = map[string][]string{
68-
"cloudflare": {"CLOUDFLARE_TOKEN"},
69-
"hetzner": {"HETZNER_API_TOKEN"},
70-
"njalla": {"NJALLA_TOKEN"},
71-
"vultr": {"VULTR_API_KEY"},
72-
"route53": nil,
73-
"gcloud": nil,
75+
acme.TypeCloudflare: {"CLOUDFLARE_TOKEN"},
76+
acme.TypeHetzner: {"HETZNER_API_TOKEN"},
77+
acme.TypeNjalla: {"NJALLA_TOKEN"},
78+
acme.TypeVultr: {"VULTR_API_KEY"},
79+
acme.TypeRoute53: nil,
80+
acme.TypeGCloud: nil,
7481
}

docs/src/routes/configuration/+page.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,29 @@ This page describes what each variable means. To see what a specific deployment
1313
is actually doing, ask the binary rather than reading a table:
1414

1515
```bash
16-
relay-server config --env-file .env
16+
docker compose run --rm -T portal config
1717
```
1818

1919
It prints every key with its effective value and where that value came from,
2020
names any key nothing reads, and reports which features are off and what is
21-
missing. `relay-server config --format env` regenerates the full list from the
22-
flag definitions, and `make check-env-example` fails when this page or
21+
missing.
22+
23+
Run it **inside the container, without `--env-file`**. Compose has already
24+
combined `.env` with the defaults declared in `docker-compose.yml`, so the
25+
report then describes the environment `docker compose up` will actually
26+
provide. `--env-file` reads a file on its own, against the relay binary's
27+
defaults — `MIN_PORT` is `0` there and `40000` under Compose — so a file that
28+
sets only `PORTAL_URL` and `UDP_ENABLED=true` is reported as
29+
`udp-transport blocked` although the deployment would enable it. Use it to
30+
inspect a file in isolation, not to predict a deployment:
31+
32+
```bash
33+
relay-server config # this process environment
34+
relay-server config --env-file .env # one file, against relay defaults
35+
```
36+
37+
`relay-server config --format env` regenerates the full list from the flag
38+
definitions, and `make check-env-example` fails when this page or
2339
`.env.example` stops mentioning a key.
2440

2541
## Relay Server Environment Variables

0 commit comments

Comments
 (0)