// Package relayurl holds the one definition of an acceptable relay URL. // // Three components need to agree on it — the config validator that decides whether the daemon starts // at all, the transport that dials the relay, and the enrollment client that registers a node before // either of them runs — and they did not. `msa-agent enroll --relay relay.example` (no scheme) and // `--relay https://relay.example/v1` (a path) both wrote a config happily; the daemon then refused it // at startup, on a node the operator had just finished installing, minutes later and in a different // process. The error arrived as "msa-agent exiting" in the journal. // // The rule is short enough to state once and nowhere else: https, a host, and nothing after it. package relayurl import ( "errors" "fmt" "net/url" ) // Validate reports whether raw can be used as a relay base URL. // // The path rule is not cosmetic. The transport builds every request URL by concatenation // (baseURL + "/v1/hello"), so a base URL carrying a query would produce // `https://relay.example?x=1/v1/hello` — an empty path and a query of "/v1/hello". A base URL with a // path would silently address endpoints under it. Both fail somewhere far from the typo. func Validate(raw string) error { if raw == "" { // Named separately because the empty string is what a flag that was never given a value looks // like, and "must be https, got """ is a worse thing to read at 2am than "is required". return errors.New("relay url is required") } parsed, err := url.Parse(raw) if err != nil { return fmt.Errorf("relay url: %w", err) } if parsed.Scheme != "https" { // Not negotiable. The relay is untrusted by design, so the channel's job is to stop a third // party from substituting a different ciphertext; over an unauthenticated channel the // end-to-end encryption above it is decorative. return fmt.Errorf("relay url must be https, got %q", parsed.Scheme) } if parsed.Host == "" { return errors.New("relay url has no host") } if parsed.Path != "" && parsed.Path != "/" { return fmt.Errorf("relay url must not carry a path, got %q", parsed.Path) } if parsed.RawQuery != "" || parsed.Fragment != "" { return errors.New("relay url must not carry a query or a fragment") } if parsed.User != nil { // Credentials in the URL would be sent on every request and are not how this authenticates: // the agent signs a challenge with its own key, and a bearer in the URL would end up in logs. return errors.New("relay url must not carry credentials") } return nil }