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
2 changes: 2 additions & 0 deletions .golangci-comprehensive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
2 changes: 2 additions & 0 deletions .golangci-quick.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ run:
- dest_s3parquet
- enrich_asn
- enrich_locality
- capture
- dest_file

formatters:
enable:
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Breaking

- **S3 uses TLS unless told otherwise.** A bare `host:port` S3 endpoint now uses TLS (it was plaintext), and `http://` is refused at startup (exit 64) unless `-s3AllowInsecure` / `S3_ALLOW_INSECURE=true`, which logs a warning and sets `xtcp2_s3_insecure 1`. TLS 1.2 is the minimum; `-s3CAFile` / `S3_CA_FILE` adds a private CA. `ConfigService.Set` refuses a plaintext endpoint without the opt-in.
- **Credentials on the command line.** The production (slim) flavors refuse `-s3AccessKey`, `-s3SecretKey` and the four `-listener*` secret flags with exit 64; development builds warn, now for the S3 flags too and even when the env var is also set. Use the `_FILE` variables.
- **`_FILE` secrets are checked.** A missing or unreadable `*_FILE` path, a non-regular file, or one readable by group or others stops startup with exit 64. It used to be ignored silently (listener tokens) or logged (S3 keys), leaving the credential empty. Mount secrets mode `0400`.
- **The debug writers are build-time opt-ins.** The netlink capture writer (`-writeFiles` / `-capturePath`) is compiled in only with `-tags capture`, which no flavor or image sets; use `nix build .#xtcp2-capture`. The `file:` destination needs `-tags dest_file` and is in the full (development) flavor only, so `xtcp2-<dest>` slim flavors and their images no longer have it. Asking for either on a binary without it exits 64 at startup with the tag to rebuild with; `ConfigService.Set` refuses it with `FailedPrecondition`. A capture build reports `+capture` in its version line, and when armed logs a warning and sets `xtcp2_debug_capture_enabled 1`.

## 0.2.1 (2026-11)

Fixes for what the first fleet rollout of 0.2.0 found: the link monitor baseline, the uplink columns, and container attribution. No schema, flag-default or `XtcpConfig` change; 0.2.0 clients and consumers work unchanged.
Expand Down
3 changes: 3 additions & 0 deletions cmd/xtcp2/admin_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ func TestEnvOverrideAdminListenerAuth(t *testing.T) {
FailureJitterMax: durationpb.New(7 * time.Millisecond),
}}
envOverrideListenerAuth(c, 0)
if err := applyListenerSecretFiles(c); err != nil {
t.Fatalf("applyListenerSecretFiles: %v", err)
}
a := c.GetAdminListenerAuth()
got := want{a.GetMode(), a.GetRawToken(), a.GetHmacSharedKey()}
if got != tc.expected {
Expand Down
30 changes: 30 additions & 0 deletions cmd/xtcp2/exit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package main

import (
"errors"

"github.com/randomizedcoder/xtcp2/pkg/xtcp"
)

// Process exit codes. Configuration the operator must change exits with
// exitUsage (sysexits EX_USAGE), so a supervisor can tell "fix the command
// line" from a runtime failure (exitFailure).
const (
exitOK = 0
exitFailure = 1
exitUsage = 64
)

// exitCodeFor maps a startup error to a process exit code.
func exitCodeFor(err error) int {
switch {
case err == nil:
return exitOK
case errors.Is(err, errUsage),
errors.Is(err, xtcp.ErrNotCompiledIn),
errors.Is(err, xtcp.ErrInsecureTransport):
return exitUsage
default:
return exitFailure
}
}
107 changes: 107 additions & 0 deletions cmd/xtcp2/exit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"

"github.com/randomizedcoder/xtcp2/gen/go/xtcp_config"
"github.com/randomizedcoder/xtcp2/pkg/xtcp"
)

func TestExitCodeFor(t *testing.T) {
t.Parallel()
cases := []struct {
name string
category string
description string
err error
expected int
}{
{"nil", "positive", "no error exits 0", nil, exitOK},
{"not_compiled_in", "negative", "a feature not in this build is a usage error",
xtcp.ErrNotCompiledIn, exitUsage},
{"wrapped_not_compiled_in", "boundary", "the sentinel is found through wrapping",
fmt.Errorf("-writeFiles 5: %w", xtcp.ErrNotCompiledIn), exitUsage},
{"other_error", "corner", "any other error is a plain failure",
errors.New("boom"), exitFailure},
}
for _, tc := range cases {
t.Run(tc.category+"/"+tc.name, func(t *testing.T) {
t.Parallel()
if got := exitCodeFor(tc.err); got != tc.expected {
t.Errorf("%s: exit = %d, want %d", tc.description, got, tc.expected)
}
})
}
}

// TestRunMain_debugWritersNotCompiledIn drives the startup refusal of the
// build-tag-gated debug writers. Each row's expectation follows what the test
// binary was built with, so the table holds under every flavor leg: without
// the tag the run is a usage error before the daemon starts; with it the
// (stubbed) daemon runs.
func TestRunMain_debugWritersNotCompiledIn(t *testing.T) {
fileIn := xtcp.CheckBuildSupport(&xtcp_config.XtcpConfig{Dest: "file:/x"}) == nil
cases := []struct {
name string
category string
description string
args []string
compiledIn bool
}{
{"capture_not_compiled", "negative", "-writeFiles 5 needs -tags capture",
[]string{"-dest", "null", "-writeFiles", "5"}, xtcp.CaptureCompiledIn},
{"file_not_compiled", "negative", "-dest file: needs -tags dest_file",
[]string{"-dest", "file:" + filepath.Join(t.TempDir(), "out.jsonl"), "-marshal", "jsonl"}, fileIn},
{"writefiles_one", "boundary", "the smallest capture budget is refused too",
[]string{"-dest", "null", "-writeFiles", "1"}, xtcp.CaptureCompiledIn},
{"writefiles_zero_default", "corner", "-writeFiles 0 starts on every build",
[]string{"-dest", "null", "-writeFiles", "0"}, true},
{"stderr_dest", "positive", "the stderr destination needs no tag",
[]string{"-dest", "stderr", "-marshal", "jsonl"}, true},
}
for _, tc := range cases {
t.Run(tc.category+"/"+tc.name, func(t *testing.T) {
envHelperReset(t)
prevArgs := os.Args
os.Args = append([]string{"xtcp2"}, tc.args...)
t.Cleanup(func() { os.Args = prevArgs })

prevProm := promHandlerStarter
promHandlerStarter = func(context.Context, *sync.WaitGroup, promParams) error { return nil }
t.Cleanup(func() { promHandlerStarter = prevProm })

prevDaemon := daemonRunner
started := false
daemonRunner = func(context.Context, context.CancelFunc, *xtcp_config.XtcpConfig) { started = true }
t.Cleanup(func() { daemonRunner = prevDaemon })

wantRC, wantStarted := exitUsage, false
if tc.compiledIn {
wantRC, wantStarted = exitOK, true
}
captureLog(t, func() {
if rc := runMain(t.Context()); rc != wantRC {
t.Errorf("%s: rc = %d, want %d", tc.description, rc, wantRC)
}
})
if started != wantStarted {
t.Errorf("%s: daemon started = %v, want %v", tc.description, started, wantStarted)
}
})
}
}

// TestVersionString_captureSuffix: a binary carrying the capture writer says
// so in its version line; every other binary does not.
func TestVersionString_captureSuffix(t *testing.T) {
if got := strings.HasSuffix(versionString(), "+capture"); got != xtcp.CaptureCompiledIn {
t.Errorf("version %q: +capture suffix = %v, want %v", versionString(), got, xtcp.CaptureCompiledIn)
}
}
157 changes: 157 additions & 0 deletions cmd/xtcp2/secrets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package main

import (
"errors"
"flag"
"fmt"
"log"
"os"
"strings"

"github.com/randomizedcoder/xtcp2/gen/go/xtcp_config"
)

// flavorProduction is "true" in the production (slim) flavors, set at link
// time by the Nix flavor matrix: -X main.flavorProduction=true. It is a
// build-time property, not a flag, so a deployment cannot switch it off.
var flavorProduction string

// productionBuild reports whether this binary is a production flavor.
func productionBuild() bool { return flavorProduction == "true" }

// errUsage marks configuration the operator must change; exit 64.
var errUsage = errors.New("usage")

// secretFlag pairs a flag that carries a credential with the _FILE env var
// that should carry it instead.
type secretFlag struct {
flag string
fileEnv string
}

// secretFlags is every flag whose value is a credential. A value given this
// way is visible in ps, /proc/<pid>/cmdline and docker inspect.
var secretFlags = []secretFlag{
{"s3AccessKey", "S3_ACCESS_KEY_FILE"},
{"s3SecretKey", "S3_SECRET_KEY_FILE"},
{"listenerRawToken", "LISTENER_RAW_TOKEN_FILE"},
{"listenerHMACSharedKey", "LISTENER_HMAC_SHARED_KEY_FILE"},
{"listenerAdminRawToken", "LISTENER_ADMIN_RAW_TOKEN_FILE"},
{"listenerAdminHMACSharedKey", "LISTENER_ADMIN_HMAC_SHARED_KEY_FILE"},
}

// checkSecretFlags looks at the flags actually given on the command line,
// whatever the environment also says. A production build refuses a non-empty
// credential flag; any other build warns once per flag.
func checkSecretFlags(fs *flag.FlagSet, production bool) error {
byName := make(map[string]secretFlag, len(secretFlags))
for _, s := range secretFlags {
byName[s.flag] = s
}
var given []secretFlag
fs.Visit(func(f *flag.Flag) {
if s, ok := byName[f.Name]; ok && f.Value.String() != "" {
given = append(given, s)
}
})
for _, s := range given {
if production {
return fmt.Errorf("%w: -%s puts a secret on the command line, where ps, /proc/<pid>/cmdline and "+
"docker inspect show it, and this production build refuses it. Put the value in a file "+
"readable only by xtcp2 and set %s to its path", errUsage, s.flag, s.fileEnv)
}
log.Printf("WARNING: -%s puts a secret on the command line (visible in ps, /proc/<pid>/cmdline, "+
"docker inspect); set %s instead. Production builds refuse this.", s.flag, s.fileEnv)
}
return nil
}

// secretFileModeMask is the permission bits a secret file must not have:
// any access for group or others.
const secretFileModeMask = 0o077

// readSecretFile reads a credential from the file a _FILE env var names. A
// missing, unreadable, non-regular or group/world-accessible file is an
// error: a credential the operator pointed at but xtcp2 cannot safely use
// must stop startup, not leave the listener or upload unauthenticated.
// Surrounding whitespace (a trailing newline) is trimmed.
func readSecretFile(env, path string) (string, error) {
fi, err := os.Stat(path) // follows symlinks, as Kubernetes secret mounts need
if err != nil {
return "", fmt.Errorf("%w: %s=%q: %w", errUsage, env, path, err)
}
if !fi.Mode().IsRegular() {
return "", fmt.Errorf("%w: %s=%q is not a regular file", errUsage, env, path)
}
if perm := fi.Mode().Perm(); perm&secretFileModeMask != 0 {
return "", fmt.Errorf("%w: %s=%q has mode %04o; a secret file must not be accessible by group or "+
"others (chmod 0400; Kubernetes secret defaultMode: 0400)", errUsage, env, path, perm)
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("%w: %s=%q: %w", errUsage, env, path, err)
}
return strings.TrimSpace(string(b)), nil
}

// secretFileTarget is one _FILE env var and where its value goes.
type secretFileTarget struct {
env string
set func(*xtcp_config.XtcpConfig, string)
}

func listenerAuth(c *xtcp_config.XtcpConfig) *xtcp_config.ListenerAuth {
if c.ListenerAuth == nil {
c.ListenerAuth = &xtcp_config.ListenerAuth{}
}
return c.ListenerAuth
}

func adminListenerAuth(c *xtcp_config.XtcpConfig) *xtcp_config.ListenerAuth {
if c.AdminListenerAuth == nil {
c.AdminListenerAuth = &xtcp_config.ListenerAuth{}
}
return c.AdminListenerAuth
}

var listenerSecretFiles = []secretFileTarget{
{"LISTENER_RAW_TOKEN_FILE", func(c *xtcp_config.XtcpConfig, v string) { listenerAuth(c).RawToken = v }},
{"LISTENER_HMAC_SHARED_KEY_FILE", func(c *xtcp_config.XtcpConfig, v string) { listenerAuth(c).HmacSharedKey = v }},
{"LISTENER_ADMIN_RAW_TOKEN_FILE", func(c *xtcp_config.XtcpConfig, v string) { adminListenerAuth(c).RawToken = v }},
{"LISTENER_ADMIN_HMAC_SHARED_KEY_FILE", func(c *xtcp_config.XtcpConfig, v string) { adminListenerAuth(c).HmacSharedKey = v }},
}

var s3SecretFiles = []secretFileTarget{
{"S3_ACCESS_KEY_FILE", func(c *xtcp_config.XtcpConfig, v string) { c.S3AccessKey = v }},
{"S3_SECRET_KEY_FILE", func(c *xtcp_config.XtcpConfig, v string) { c.S3SecretKey = v }},
}

// applySecretFileTargets reads each set _FILE env var into c. Applied after
// the inline env overrides, so a file wins over the inline variable.
func applySecretFileTargets(c *xtcp_config.XtcpConfig, targets []secretFileTarget) error {
for _, t := range targets {
path, ok := os.LookupEnv(t.env)
if !ok || path == "" {
continue
}
v, err := readSecretFile(t.env, path)
if err != nil {
return err
}
t.set(c, v)
}
return nil
}

// applyListenerSecretFiles reads the listener credential _FILE variables.
func applyListenerSecretFiles(c *xtcp_config.XtcpConfig) error {
return applySecretFileTargets(c, listenerSecretFiles)
}

// applySecretFiles reads every credential _FILE variable into c.
func applySecretFiles(c *xtcp_config.XtcpConfig) error {
if err := applySecretFileTargets(c, listenerSecretFiles); err != nil {
return err
}
return applySecretFileTargets(c, s3SecretFiles)
}
Loading