Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions cmd/pyrycode-relay/deps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package main

import (
"bytes"
"encoding/json"
"errors"
"io"
"os/exec"
"testing"
)

// TestBinaryDoesNotImportPprof asserts that net/http/pprof is not in the
// transitive import set of the cmd/pyrycode-relay package. A blank-import
// of net/http/pprof anywhere in the dependency graph registers
// /debug/pprof/* handlers on http.DefaultServeMux and can expose an
// unauthenticated profiler endpoint. The boot-time CheckListenerPorts
// guard catches the :6060-bind variant; this build-graph test catches
// the handler-registration variant that would attach to an existing
// mux rather than opening a new port.
//
// Implementation: shell out to `go list -deps -json` against the
// canonical import path. The output is a stream of concatenated JSON
// objects (one per dep); we decode in a loop and fail if any
// ImportPath equals "net/http/pprof".
func TestBinaryDoesNotImportPprof(t *testing.T) {
t.Parallel()

goBin, err := exec.LookPath("go")
if err != nil {
t.Skipf("go binary not in PATH: %v", err)
}

const importPath = "github.com/pyrycode/pyrycode-relay/cmd/pyrycode-relay"
cmd := exec.Command(goBin, "list", "-deps", "-json", importPath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("go list -deps -json %s: %v\nstderr: %s", importPath, err, stderr.String())
}

dec := json.NewDecoder(&stdout)
for {
var pkg struct {
ImportPath string `json:"ImportPath"`
}
if err := dec.Decode(&pkg); err != nil {
if errors.Is(err, io.EOF) {
break
}
t.Fatalf("decoding go list output: %v", err)
}
if pkg.ImportPath == "net/http/pprof" {
t.Fatalf("net/http/pprof is in the transitive imports of %s; "+
"remove the import or it will register debug handlers on "+
"http.DefaultServeMux", importPath)
}
}
}
60 changes: 60 additions & 0 deletions cmd/pyrycode-relay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"log/slog"
"net/http"
"os"
"slices"
"syscall"
"time"

Expand Down Expand Up @@ -119,6 +120,22 @@ func main() {
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
port, err := relay.ListenerPort(srv.Addr)
if err != nil {
logger.Error("refusing to start: invalid listener address",
"err", err, "addr", srv.Addr)
os.Exit(2)
}
expected := map[uint16]struct{}{port: {}}
actual := map[uint16]struct{}{port: {}}
if err := relay.CheckListenerPorts(expected, actual); err != nil {
surplus, expectedList := listenerPortLists(expected, actual)
logger.Error("refusing to start: unexpected listener",
"err", err,
"unexpected_ports", surplus,
"expected_ports", expectedList)
os.Exit(2)
}
if err := srv.ListenAndServe(); err != nil {
logger.Error("listen failed", "err", err)
os.Exit(1)
Expand Down Expand Up @@ -154,6 +171,29 @@ func main() {
IdleTimeout: 120 * time.Second,
}

httpsPort, err := relay.ListenerPort(httpsSrv.Addr)
if err != nil {
logger.Error("refusing to start: invalid listener address",
"err", err, "addr", httpsSrv.Addr)
os.Exit(2)
}
httpPort, err := relay.ListenerPort(httpSrv.Addr)
if err != nil {
logger.Error("refusing to start: invalid listener address",
"err", err, "addr", httpSrv.Addr)
os.Exit(2)
}
expected := map[uint16]struct{}{443: {}, 80: {}}
actual := map[uint16]struct{}{httpsPort: {}, httpPort: {}}
if err := relay.CheckListenerPorts(expected, actual); err != nil {
surplus, expectedList := listenerPortLists(expected, actual)
logger.Error("refusing to start: unexpected listener",
"err", err,
"unexpected_ports", surplus,
"expected_ports", expectedList)
os.Exit(2)
}

logger.Info("starting", "version", Version, "mode", "autocert",
"domain", *domain, "cert_cache", *certCache)

Expand All @@ -170,6 +210,26 @@ func main() {
}
}

// listenerPortLists returns the ascending-sorted surplus (actual\expected)
// and expected port slices, suitable for emission as []uint16 fields on
// the boot-refusal log line. Computed in main (not in relay) so the
// CheckListenerPorts API stays a single error return — the duplicate
// pass is cheap at boot and runs at most once.
func listenerPortLists(expected, actual map[uint16]struct{}) (surplus, expectedSorted []uint16) {
for p := range actual {
if _, ok := expected[p]; !ok {
surplus = append(surplus, p)
}
}
slices.Sort(surplus)
expectedSorted = make([]uint16, 0, len(expected))
for p := range expected {
expectedSorted = append(expectedSorted, p)
}
slices.Sort(expectedSorted)
return surplus, expectedSorted
}

func defaultCertCache() string {
if home, err := os.UserHomeDir(); err == nil {
return home + "/.pyrycode-relay/certs"
Expand Down
1 change: 1 addition & 0 deletions docs/knowledge/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ One-line pointers into the evergreen knowledge base. Newest entries at the top o

## Features

- [Listener port allowlist (boot-time refusal)](features/listener-port-allowlist.md) — relay refuses to start (exit 2) if the set of TCP ports it is *about to bind* (`http.Server.Addr` values) contains any port outside an explicit expected set derived from parsed flags: `{443, 80}` in autocert mode, `{<port>}` in `--insecure-listen :<port>` mode. Catches stray `net/http/pprof :6060` listeners, env-flipped debug ports, accidentally-enabled metrics exporters. Three exports from `internal/relay/listeners.go`: `ErrUnexpectedListener` sentinel (branchable via `errors.Is`; wrapped message names surplus + expected ports both ascending so the failure log is deterministic across `map`-iteration runs and grep-friendly), `ListenerPort(addr string) (uint16, error)` parsing `":443"` / `"127.0.0.1:8080"` / `"[::1]:443"` with an explicit reject of port 0 (the ephemeral-placeholder trap — would smuggle an unknown bound port past the actual-set construction), and `CheckListenerPorts(expected, actual map[uint16]struct{}) error` (pure set-difference). **Asymmetric by design**: surplus = error, missing = nil (a failure to bind an expected port surfaces as `ListenAndServe`'s own bind error at exit 1; duplicating the signal at boot would clutter logs). **Port-only**: interface binding (`127.0.0.1` vs `0.0.0.0`) intentionally out of scope on a single-instance internet-exposed deploy. **Reports all surplus in one error** so a manifest enabling several debug surfaces fails in one boot rather than N restart cycles. Wired into each listener branch separately in `cmd/pyrycode-relay/main.go` (different `http.Server` shapes; lifting a helper would invent surface area without a second consumer); structured log fields `unexpected_ports` + `expected_ports` recomputed in `main` via unexported `listenerPortLists` so `Check…` stays a single-error return. Paired with `TestBinaryDoesNotImportPprof` in `cmd/pyrycode-relay/deps_test.go` — shells out to `go list -deps -json`, catches the `import _ "net/http/pprof"` handler-registration variant that attaches `/debug/pprof/*` to `http.DefaultServeMux` *without* opening a new port (the runtime check would miss it). Belt-and-suspenders means different fabric: stochastic-ish runtime guard + deterministic compile-time test, neither alone is complete. Exit 2 = config-rejected-at-boot, harmonised across the sentinel family (architect overrode the AC's literal `os.Exit(1)`) (#81).
- [Env-var config validator (boot-time refusal)](features/env-config-validator.md) — table-driven validation of every env var the relay reads at boot. Single source of truth is the unexported `envContracts []envContract` registry in `internal/relay/env_config.go`; each row carries `name`, `required` bool, and an inline `validate func(string) error`. `CheckEnvConfig(lookup func(string) (string, bool)) error` walks the registry and returns the structured `*ErrInvalidConfig{Key, Reason}` on the first failure (`Reason` is `"missing"` or `"malformed-value: <err>"`); the package-level sentinel `ErrInvalidConfigSentinel` is matched via a custom `Is` method (not `Unwrap`, which would double-print the message prefix) so `errors.Is(err, ErrInvalidConfigSentinel)` and `errors.As(err, &cfgErr)` form a dual contract. The `func(string) (string, bool)` (= `os.LookupEnv` shape) getter coexists with #77's `func(string) string` getter — the presence bit is necessary here to distinguish "missing-but-required" from "present-but-empty", semantically inert for `IsProductionMode`'s exact-`"1"` match. **Ordering is load-bearing**: wired in `main.go` BEFORE `CheckInsecureListenInProduction` so a typo like `PYRYCODE_RELAY_PRODUCTION=true` cannot slip through `IsProductionMode`'s silent-non-production fallback and reach the insecure-listen guard with an unvalidated value. Today's registry has one row (`PYRYCODE_RELAY_PRODUCTION`, optional-but-format-validated); future env-var reads register here at code-review time. `checkEnvConfigWith(lookup, contracts)` is the parameterised inner used by the `required: true` test case (today's production table has no required entries). Exit 2 = config-rejected-at-boot, matching the sibling refusals (#9, #77, #79) (#80).
- [Linux capability allowlist (boot-time refusal)](features/capability-allowlist.md) — relay parses `/proc/self/status`'s `CapEff:` hex mask at boot and refuses to start (exit 2) if any bit is set outside `AllowedCapabilities` (currently `{CAP_NET_BIND_SERVICE}` only, motivated by autocert binding `:80`/`:443` from uid 65532 in the distroless image). Exported sentinel `ErrUnexpectedCapability` is branchable via `errors.Is`; the wrapped error names every offending bit symbolically (`CAP_SYS_ADMIN (bit 21)` or `bit 63` for unknown), lists the allowlist contents, and embeds the operator fix string. `CapEff` only — `CapPrm/CapBnd/CapInh` would broaden false-positives (legitimate K8s default policy grants wide CapBnd) without adding load-bearing protection (relay never `capset(2)`s). Linux/non-Linux split at compile time via the new `_<goos>.go` / `_other.go` build-tag convention (see ADR-0009); non-Linux GOOS logs one skip line and returns nil. Unconditional — no production-mode gating, no env-var bypass, because stray capabilities are never legitimate. Reader-boundary test seam (`func() (string, error)`) exercises the parse + mask check end-to-end without touching real `/proc`. Joins the boot-time-refusal sentinel family (#9, #77, #79; future #78) (#79).
- [Production-mode contract & startup refusals](features/production-mode.md) — `PYRYCODE_RELAY_PRODUCTION=1` env-var contract (exact-string match, lazy read via injected getter, mirrors `PYRYCODE_RELAY_SINGLE_INSTANCE` shape from #64/#65) plus the boot-time checks that consume it. **#77** introduced `relay.CheckInsecureListenInProduction` + exported `ErrInsecureListenInProduction` sentinel (branchable via `errors.Is`) firing when production mode is on AND `--insecure-listen` is set. **#78** added the second consumer: `relay.CheckRunningAsRoot(geteuid, getenv)` + exported `ErrRunningAsRoot` sentinel firing when production mode is on AND `syscall.Geteuid() == 0`, closing the deploy-time gap (`docker run --user 0`, `securityContext.runAsUser: 0`, hand-edited Dockerfile dropping `USER`) that escapes the CI non-root-build contract (#32 Dockerfile, #68 Trivy). Both wired in `cmd/pyrycode-relay/main.go` after flag-parse with `os.Exit(2)` (config-rejected-at-boot, distinct from runtime-failure exit 1) and structured log fields: `env_var` carries the name only (never the value, even though `effective_uid` carries the kernel-supplied int — log-injection structurally impossible), one-line `fix` listing valid resolutions. `IsProductionMode` exported so siblings compose on the same predicate without re-reading the env var. Test seams: `func(string) string` for env, `func() int` for euid — both the smallest possible (no interface, no struct, no package-level var) and the only way to exercise the uid-0 branch in a unit test without re-execing the test binary as root. Two instances of the shape (#77, #78) now codify the "sibling boot-check" pattern; `Config.Validate()` consolidation deferred until ~5 checks exist (#77, #78).
Expand Down
Loading