Types 0x03/0x04/0x05 land with a bounded columnar train buffer (head kept, truncation declared) and grant-free multi-part reports - a report row is smaller than the packet it answers, so $3.4 holds without a grant. The read loop now collects TTL/TOS cmsgs on Linux, replacing the 0xFF stubs in the observation block with what the kernel saw; downtrain gained a dscp parameter, so DSCP survival is measurable in both directions. Rate limiting ($2.5) exists now: per-credential AND per-source buckets, 429 on the control plane, silent drop on the data plane after the HMAC gate and before the replay window. UDP ceilings default above the largest legitimate run - a limit that clips a real measurement produces a confidently wrong number. Every granted packet carries its action_id at payload[8:16]; overlapping actions were unattributable before. Canary DNS logs now honor the stated 24h privacy default. /admin/enroll-tokens answers the spec's JSON shape. protocol_version 1.0.1 (additive). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
455 lines
24 KiB
Go
455 lines
24 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package config resolves server configuration with the precedence
|
|
// flags > environment > defaults. In Docker mode (detected or forced with
|
|
// --docker) env vars are the expected source; native installs typically use
|
|
// flags baked into the systemd unit by --install-systemd.
|
|
package config
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Config holds everything the daemon needs. Every field maps to exactly one
|
|
// env var (ECHOLOT_*) and one flag; keep the three in sync when adding fields.
|
|
type Config struct {
|
|
// Control plane (HTTPS + JSON). TLS is pin-based per spec §1: self-signed
|
|
// is first-class, so cert+key are generated if the paths don't exist.
|
|
ControlListen string // ECHOLOT_CONTROL_LISTEN / --control-listen
|
|
TLSCert string // ECHOLOT_TLS_CERT / --tls-cert
|
|
TLSKey string // ECHOLOT_TLS_KEY / --tls-key
|
|
|
|
// Data plane
|
|
UDPListen string // ECHOLOT_UDP_LISTEN / --udp-listen (spec default port 8442)
|
|
TCPListen string // ECHOLOT_TCP_LISTEN / --tcp-listen (spec default port 8441)
|
|
StunListen string // ECHOLOT_STUN_LISTEN / --stun-listen (spec default 3478; empty disables)
|
|
DNSListen string // ECHOLOT_DNS_LISTEN / --dns-listen (canary zone; empty disables)
|
|
CanaryZone string // ECHOLOT_CANARY_ZONE / --canary-zone (e.g. c.echo-lot.app)
|
|
// Optional cleartext HTTP-echo listener (spec §4 plaintext-path test).
|
|
// Default empty = off; it exposes only POST /v1/echo, no auth, no secrets.
|
|
HTTPEchoListen string // ECHOLOT_HTTP_ECHO_LISTEN / --http-echo-listen
|
|
// Comma-separated anchors for the egress-MTU self-proof (host or ip).
|
|
MTUProbeTargets string // ECHOLOT_MTU_PROBE_TARGETS / --mtu-probe-targets
|
|
|
|
// Admin UI / health listener (spec §7: localhost-only by default)
|
|
AdminListen string // ECHOLOT_ADMIN_LISTEN / --admin-listen
|
|
|
|
// ReservedAddrs are IPs reserved for measurement: addresses whose listening state must stay
|
|
// known, so that "nothing answered on port 443" is a fact about the network rather than a
|
|
// fact about this server's configuration. Enforced by CheckReserved.
|
|
ReservedAddrs string // ECHOLOT_RESERVED_ADDRS / --reserved-addrs
|
|
|
|
// ControlHostname lets the control plane share port 443 with the admin UI.
|
|
//
|
|
// They cannot share a certificate: the control plane is trusted by SPKI pin and so uses a
|
|
// long-lived self-signed certificate, while a browser needs one a CA vouches for. One name on
|
|
// one port means one certificate, so sharing the port requires two names — this one selects
|
|
// the pinned certificate and the control-plane routes by SNI, everything else gets the admin
|
|
// UI. Empty leaves the control plane on its own listener only.
|
|
//
|
|
// Why bother: captive portals and corporate firewalls routinely permit only 80 and 443, which
|
|
// are exactly the networks this tool exists to diagnose. A control plane on 8443 is
|
|
// unreachable precisely when it matters most.
|
|
ControlHostname string // ECHOLOT_CONTROL_HOSTNAME / --control-hostname
|
|
|
|
// State directory: device store, generated TLS material.
|
|
StateDir string // ECHOLOT_STATE_DIR / --state-dir
|
|
|
|
// Profile identity
|
|
Name string // ECHOLOT_NAME / --name (profile "name", e.g. "homelab")
|
|
|
|
// Self-update (native mode only; ignored in containers — images update
|
|
// by pulling a new tag). Empty = disabled. Value: Gitea repo API base,
|
|
// e.g. https://git.example.net/api/v1/repos/owner/repo
|
|
SelfUpdateAPI string // ECHOLOT_SELF_UPDATE_API / --self-update-api
|
|
|
|
// SelfUpdatePubKey overrides the release-signing public key baked into the binary
|
|
// (selfupdate.DefaultPublicKeyB64) — for operators running their own release pipeline
|
|
// against their own Gitea. Empty = the built-in project key.
|
|
SelfUpdatePubKey string // ECHOLOT_SELF_UPDATE_PUBKEY / --self-update-pubkey
|
|
|
|
// Uploaded-run storage. The default is "anonymous": any enrolled device may upload,
|
|
// which is what a self-hosted server wants. Operators of shared servers turn it down.
|
|
UploadsMode string // ECHOLOT_UPLOADS / --uploads (off|anonymous|account)
|
|
UploadMaxBytes int64 // ECHOLOT_UPLOAD_MAX_BYTES / --upload-max-bytes
|
|
UploadRetentionDays int // ECHOLOT_UPLOAD_RETENTION_DAYS / --upload-retention-days
|
|
UploadMaxRuns int // ECHOLOT_UPLOAD_MAX_RUNS / --upload-max-runs (per device)
|
|
UploadMinAnon string // ECHOLOT_UPLOAD_MIN_ANONYMIZATION / --upload-min-anonymization
|
|
|
|
// Rate limits (spec §2.5), each applied per credential and per source IP. 0 disables a
|
|
// ceiling. The UDP ceilings are deliberately above the largest legitimate run (a 200 Mbps
|
|
// upstream throughput test is ~21k pps of 1472-byte packets), so they only ever catch abuse
|
|
// — a rate limit that clips a real measurement produces a confidently wrong number.
|
|
RateSessionsPerMin int // ECHOLOT_RATE_SESSIONS_PER_MIN / --rate-sessions-per-min
|
|
RateActionsPerMin int // ECHOLOT_RATE_ACTIONS_PER_MIN / --rate-actions-per-min
|
|
RateUDPPps int // ECHOLOT_RATE_UDP_PPS / --rate-udp-pps
|
|
RateUDPKbps int // ECHOLOT_RATE_UDP_KBPS / --rate-udp-kbps
|
|
|
|
// How long canary DNS query logs are kept, in hours (spec §6; privacy default 24).
|
|
DNSLogRetentionH int // ECHOLOT_DNS_LOG_RETENTION_H / --dns-log-retention-h
|
|
|
|
// Client compatibility window. Bounds are SemVer; an empty maximum means unbounded. The
|
|
// defaults sit at breaking boundaries, so shipping a patch never requires changing them.
|
|
MinAppVersion string // ECHOLOT_MIN_APP_VERSION / --min-app-version
|
|
MaxAppVersion string // ECHOLOT_MAX_APP_VERSION / --max-app-version (exclusive)
|
|
|
|
// Where clients reach the control plane, for enrollment links. Empty = derive from the
|
|
// first control listen address.
|
|
PublicControlURL string // ECHOLOT_PUBLIC_URL / --public-url
|
|
|
|
// Identity provider. Empty issuer disables sign-in entirely; the server is a relying
|
|
// party and never stores passwords.
|
|
OIDCIssuer string // ECHOLOT_OIDC_ISSUER / --oidc-issuer
|
|
OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id (confidential, admin UI)
|
|
OIDCAppClientID string // ECHOLOT_OIDC_APP_CLIENT_ID / --oidc-app-client-id (public, the phone app)
|
|
// Issuer for the app's client, when the IdP gives each application its own.
|
|
//
|
|
// Authentik derives the issuer from the application slug, so two applications mean two
|
|
// issuers — and a token's `iss` must match the one that minted it. Empty means both clients
|
|
// share ECHOLOT_OIDC_ISSUER, which is what IdPs with a single global issuer do.
|
|
OIDCAppIssuer string // ECHOLOT_OIDC_APP_ISSUER / --oidc-app-issuer
|
|
OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group
|
|
|
|
// Break-glass admin username; the password lives hashed in the state store.
|
|
AdminUser string // ECHOLOT_ADMIN_USER / --admin-user
|
|
|
|
// Secret for the *confidential* admin client. Prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE: a path
|
|
// keeps the secret out of the environment, where it is readable by anything that can see
|
|
// /proc/<pid>/environ and lands in every dump of the unit's configuration.
|
|
OIDCClientSecret string // ECHOLOT_OIDC_CLIENT_SECRET / _FILE
|
|
|
|
// Where the admin UI is reachable, used to build the OIDC redirect URI. Must match what is
|
|
// registered at the IdP exactly.
|
|
AdminBaseURL string // ECHOLOT_ADMIN_BASE_URL / --admin-base-url
|
|
// TLS for the admin listener. Without these it serves plaintext, which is only acceptable on
|
|
// loopback — see checkAdminExposure.
|
|
AdminTLSCert string // ECHOLOT_ADMIN_TLS_CERT / --admin-tls-cert
|
|
AdminTLSKey string // ECHOLOT_ADMIN_TLS_KEY / --admin-tls-key
|
|
// Deliberate override for serving the admin UI in plaintext off loopback, so that decision
|
|
// is made rather than stumbled into.
|
|
AdminInsecure bool // ECHOLOT_ADMIN_INSECURE / --admin-insecure
|
|
|
|
// Port-80 listener that answers ACME HTTP-01 challenges and redirects everything else to
|
|
// the admin UI. Empty disables it. HTTP-01 always arrives on port 80 — the CA picks the
|
|
// port — so this never collides with the admin UI on 443.
|
|
ACMEHTTPListen string // ECHOLOT_ACME_HTTP_LISTEN / --acme-http-listen
|
|
// Directory an ACME client writes challenge tokens into. Defaults to <state-dir>/acme.
|
|
ACMEWebroot string // ECHOLOT_ACME_WEBROOT / --acme-webroot
|
|
|
|
// Mode
|
|
Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces)
|
|
}
|
|
|
|
// secretOr reads ECHOLOT_<key>, or the contents of the file named by ECHOLOT_<key>_FILE.
|
|
//
|
|
// The file form exists because a secret in the environment is readable by anything that can see
|
|
// /proc/<pid>/environ and lands in every dump of the unit's configuration. A path costs nothing
|
|
// and keeps the value in one file whose permissions an operator can reason about.
|
|
func secretOr(key, def string) string {
|
|
if path := envOr(key+"_FILE", ""); path != "" {
|
|
if b, err := os.ReadFile(path); err == nil {
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
}
|
|
return envOr(key, def)
|
|
}
|
|
|
|
// envInt reads ECHOLOT_<key> as an integer with a fallback.
|
|
func envInt(key string, def int) int {
|
|
if v := envOr(key, ""); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// envOr reads ECHOLOT_<key> with a fallback.
|
|
func envOr(key, def string) string {
|
|
if v, ok := os.LookupEnv("ECHOLOT_" + key); ok {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// Load parses flags/env. Returns the config plus the action flags that make
|
|
// the process do something other than serve (install/uninstall/update).
|
|
func Load(args []string) (*Config, *Actions, error) {
|
|
fs := flag.NewFlagSet("echolot-server", flag.ContinueOnError)
|
|
c := &Config{}
|
|
a := &Actions{}
|
|
|
|
fs.StringVar(&c.ControlListen, "control-listen", envOr("CONTROL_LISTEN", ":8443"), "control-plane HTTPS listen address(es), comma-separated")
|
|
fs.StringVar(&c.TLSCert, "tls-cert", envOr("TLS_CERT", ""), "TLS cert path (empty: self-signed in state dir)")
|
|
fs.StringVar(&c.TLSKey, "tls-key", envOr("TLS_KEY", ""), "TLS key path (empty: self-signed in state dir)")
|
|
fs.StringVar(&c.UDPListen, "udp-listen", envOr("UDP_LISTEN", ":8442"), "UDP data-plane listen address(es), comma-separated")
|
|
fs.StringVar(&c.TCPListen, "tcp-listen", envOr("TCP_LISTEN", ":8441"), "TCP echo listen address(es), comma-separated")
|
|
fs.StringVar(&c.StunListen, "stun-listen", envOr("STUN_LISTEN", ":3478"), "STUN listen address(es), comma-separated; empty disables (spec §4)")
|
|
fs.StringVar(&c.DNSListen, "dns-listen", envOr("DNS_LISTEN", ""), "canary-DNS listen address(es) udp+tcp/53, comma-separated; empty disables (spec §6.1)")
|
|
fs.StringVar(&c.CanaryZone, "canary-zone", envOr("CANARY_ZONE", ""), "authoritative canary zone, e.g. c.echo-lot.app")
|
|
fs.StringVar(&c.HTTPEchoListen, "http-echo-listen", envOr("HTTP_ECHO_LISTEN", ""), "optional CLEARTEXT http-echo listen address(es); empty disables (spec §4)")
|
|
fs.StringVar(&c.MTUProbeTargets, "mtu-probe-targets", envOr("MTU_PROBE_TARGETS", "1.1.1.1,2606:4700:4700::1111"), "egress-MTU self-proof anchors, comma-separated")
|
|
fs.StringVar(&c.AdminListen, "admin-listen", envOr("ADMIN_LISTEN", "127.0.0.1:8444"), "admin/health listen address (keep localhost)")
|
|
fs.StringVar(&c.ControlHostname, "control-hostname", envOr("CONTROL_HOSTNAME", ""), "hostname that selects the pinned control-plane certificate when sharing the admin UI's port")
|
|
fs.StringVar(&c.ReservedAddrs, "reserved-addrs", envOr("RESERVED_ADDRS", ""), "comma-separated IPs reserved for measurement; no listener but the STUN alternate may bind them")
|
|
fs.StringVar(&c.StateDir, "state-dir", envOr("STATE_DIR", defaultStateDir()), "state directory (device store, generated TLS)")
|
|
fs.StringVar(&c.Name, "name", envOr("NAME", "echolot"), "server profile name")
|
|
fs.StringVar(&c.SelfUpdateAPI, "self-update-api", envOr("SELF_UPDATE_API", ""), "Gitea repo API base for self-update; empty disables")
|
|
fs.StringVar(&c.SelfUpdatePubKey, "self-update-pubkey", envOr("SELF_UPDATE_PUBKEY", ""), "release-signing public key (base64 ed25519) self-update verifies against; empty uses the built-in project key")
|
|
fs.StringVar(&c.UploadsMode, "uploads", envOr("UPLOADS", "anonymous"), "who may upload measurement runs: off|anonymous|account")
|
|
fs.Int64Var(&c.UploadMaxBytes, "upload-max-bytes", int64(envInt("UPLOAD_MAX_BYTES", 4<<20)), "largest accepted uploaded run, bytes")
|
|
fs.IntVar(&c.UploadRetentionDays, "upload-retention-days", envInt("UPLOAD_RETENTION_DAYS", 90), "delete uploaded runs older than this; 0 disables")
|
|
fs.IntVar(&c.UploadMaxRuns, "upload-max-runs", envInt("UPLOAD_MAX_RUNS", 200), "keep at most this many runs per device; 0 disables")
|
|
fs.StringVar(&c.UploadMinAnon, "upload-min-anonymization", envOr("UPLOAD_MIN_ANONYMIZATION", "full"), "least anonymization accepted: full|balanced|strict")
|
|
fs.StringVar(&c.OIDCIssuer, "oidc-issuer", envOr("OIDC_ISSUER", ""), "OpenID Connect issuer URL; empty disables sign-in")
|
|
fs.StringVar(&c.OIDCClientID, "oidc-client-id", envOr("OIDC_CLIENT_ID", ""), "confidential OIDC client id for the admin UI")
|
|
fs.StringVar(&c.OIDCAppClientID, "oidc-app-client-id", envOr("OIDC_APP_CLIENT_ID", ""), "public OIDC client id used by the Android app (PKCE)")
|
|
fs.StringVar(&c.OIDCAppIssuer, "oidc-app-issuer", envOr("OIDC_APP_ISSUER", ""), "issuer for the app client when the IdP uses per-application issuers; empty = same as --oidc-issuer")
|
|
fs.StringVar(&c.OIDCAdminGroup, "oidc-admin-group", envOr("OIDC_ADMIN_GROUP", ""), "group claim required for admin access; empty means nobody is an admin via OIDC")
|
|
fs.StringVar(&c.OIDCClientSecret, "oidc-client-secret", secretOr("OIDC_CLIENT_SECRET", ""), "secret for the confidential admin client; prefer ECHOLOT_OIDC_CLIENT_SECRET_FILE")
|
|
fs.StringVar(&c.AdminBaseURL, "admin-base-url", envOr("ADMIN_BASE_URL", ""), "public URL of the admin UI, for the OIDC redirect (e.g. https://admin.example.net)")
|
|
fs.StringVar(&c.AdminTLSCert, "admin-tls-cert", envOr("ADMIN_TLS_CERT", ""), "TLS certificate for the admin listener")
|
|
fs.StringVar(&c.AdminTLSKey, "admin-tls-key", envOr("ADMIN_TLS_KEY", ""), "TLS key for the admin listener")
|
|
fs.BoolVar(&c.AdminInsecure, "admin-insecure", envOr("ADMIN_INSECURE", "") == "1", "allow the admin UI in plaintext off loopback (you are on your own)")
|
|
fs.StringVar(&c.ACMEHTTPListen, "acme-http-listen", envOr("ACME_HTTP_LISTEN", ""), "port-80 listener for ACME HTTP-01 challenges and http->https redirects")
|
|
fs.StringVar(&c.ACMEWebroot, "acme-webroot", envOr("ACME_WEBROOT", ""), "directory an ACME client writes challenges into (default <state-dir>/acme)")
|
|
fs.StringVar(&c.PublicControlURL, "public-url", envOr("PUBLIC_URL", ""), "public control-plane URL for enrollment links, e.g. https://probe.example.net:8443")
|
|
fs.IntVar(&c.RateSessionsPerMin, "rate-sessions-per-min", envInt("RATE_SESSIONS_PER_MIN", 10), "per-credential and per-IP ceiling on session creation (spec §2.5); 0 disables")
|
|
fs.IntVar(&c.RateActionsPerMin, "rate-actions-per-min", envInt("RATE_ACTIONS_PER_MIN", 60), "per-credential and per-IP ceiling on §5 actions; 0 disables")
|
|
fs.IntVar(&c.RateUDPPps, "rate-udp-pps", envInt("RATE_UDP_PPS", 25_000), "per-credential and per-IP data-plane packet ceiling, packets/s, silent drop; 0 disables")
|
|
fs.IntVar(&c.RateUDPKbps, "rate-udp-kbps", envInt("RATE_UDP_KBPS", 250_000), "per-credential and per-IP data-plane byte ceiling, kbit/s, silent drop; 0 disables")
|
|
fs.IntVar(&c.DNSLogRetentionH, "dns-log-retention-h", envInt("DNS_LOG_RETENTION_H", 24), "hours canary DNS query logs are kept (spec §6 privacy default 24); 0 keeps until the ring overwrites")
|
|
fs.StringVar(&c.MinAppVersion, "min-app-version", envOr("MIN_APP_VERSION", "0.2.0"), "oldest app version this server will serve (SemVer, inclusive)")
|
|
fs.StringVar(&c.MaxAppVersion, "max-app-version", envOr("MAX_APP_VERSION", "1.0.0"), "first app version this server will refuse (SemVer, exclusive); empty = unbounded")
|
|
fs.BoolVar(&c.Docker, "docker", envOr("DOCKER", "") == "1", "force container mode (config from env, no systemd/self-update)")
|
|
|
|
fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit")
|
|
fs.BoolVar(&a.UninstallSystemd, "uninstall-systemd", false, "remove the systemd unit and exit")
|
|
var daemon bool
|
|
fs.BoolVar(&a.Serve, "serve", false, "run the server (bind listeners and answer requests)")
|
|
fs.BoolVar(&daemon, "daemon", false, "alias for --serve")
|
|
fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false,
|
|
"set the break-glass admin password (username as --admin-user, password read from stdin) and exit")
|
|
fs.StringVar(&c.AdminUser, "admin-user", envOr("ADMIN_USER", "admin"), "username for the break-glass admin")
|
|
fs.StringVar(&a.MintEnrollToken, "mint-enroll-token", "", "mint a single-use enrollment link (argument is a note for the audit log) and exit")
|
|
fs.BoolVar(&a.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit")
|
|
fs.BoolVar(&a.Version, "version", false, "print version and exit")
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !c.Docker {
|
|
c.Docker = inContainer()
|
|
}
|
|
a.Serve = a.Serve || daemon
|
|
// No verb at all means the caller has not said what they want. Usage is the answer, and it
|
|
// is a usage error rather than success — otherwise a service manager sees a clean exit and
|
|
// concludes the server ran and finished.
|
|
if !a.Serve && !a.InstallSystemd && !a.UninstallSystemd && !a.SelfUpdate &&
|
|
!a.SetAdminPassword && !a.Version && a.MintEnrollToken == "" {
|
|
a.Help = true
|
|
}
|
|
if a.Serve {
|
|
if err := c.checkAdminExposure(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if err := c.CheckReserved(c.Listeners()); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
if c.Docker && (a.InstallSystemd || a.UninstallSystemd || a.SelfUpdate) {
|
|
return nil, nil, fmt.Errorf("systemd/self-update actions are native-mode only (container detected; override with ECHOLOT_DOCKER=0 if this is wrong)")
|
|
}
|
|
return c, a, nil
|
|
}
|
|
|
|
// Usage prints the verbs first and the tuning flags second, because the question someone has
|
|
// when they run this by name is "what does it do", not "what can I set".
|
|
func Usage(w io.Writer) {
|
|
fmt.Fprint(w, `echolot-server — the Echolot probe server
|
|
|
|
USAGE
|
|
echolot-server --serve run the server
|
|
echolot-server --version print the version
|
|
echolot-server --install-systemd install and enable a systemd unit
|
|
echolot-server --uninstall-systemd remove it
|
|
echolot-server --self-update replace this binary with the latest release
|
|
echolot-server --set-admin-password set the break-glass admin password (stdin)
|
|
echolot-server --help full flag list
|
|
|
|
Every flag can also be set as an environment variable: --control-listen becomes
|
|
ECHOLOT_CONTROL_LISTEN. In a container, configuration comes from the environment.
|
|
|
|
Running with no verb prints this and exits non-zero: starting to serve the internet
|
|
should be something you asked for.
|
|
`)
|
|
}
|
|
|
|
// checkAdminExposure refuses to serve an unencrypted admin UI on a non-loopback address.
|
|
//
|
|
// The admin session cookie is a bearer credential for everything this server can do, and the OIDC
|
|
// authorization code arrives in a URL. In plaintext, both are readable by anyone on the path — and
|
|
// on a globally routable address "the path" means the internet. This is a hard stop rather than a
|
|
// warning because a warning in a log is not read by the person who most needs it, and because the
|
|
// two safe answers are cheap: bind to loopback and tunnel, or supply a certificate.
|
|
func (c *Config) checkAdminExposure() error {
|
|
if c.AdminTLSCert != "" || c.AdminInsecure {
|
|
return nil
|
|
}
|
|
for _, addr := range Addrs(c.AdminListen) {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
if host == "" || ip == nil || ip.IsLoopback() {
|
|
continue // loopback, or a name we cannot judge; RFC 8252 blesses loopback plaintext
|
|
}
|
|
return fmt.Errorf(
|
|
"refusing to serve the admin UI in plaintext on %s: the session cookie and the OIDC "+
|
|
"authorization code would cross the network in the clear.\n"+
|
|
" Fix it one of three ways:\n"+
|
|
" - bind to 127.0.0.1 and reach it over an SSH tunnel (no certificate needed)\n"+
|
|
" - set ECHOLOT_ADMIN_TLS_CERT and ECHOLOT_ADMIN_TLS_KEY\n"+
|
|
" - set ECHOLOT_ADMIN_INSECURE=1 if you genuinely mean it", addr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Listeners enumerates every configured listen spec, for CheckReserved.
|
|
//
|
|
// Kept as one list here rather than checked at each call site, so a listener added later is
|
|
// caught by the compiler when this function is updated — and, more to the point, so that the
|
|
// person adding one sees the reserved-address rule exists at all.
|
|
func (c *Config) Listeners() []Listener {
|
|
return []Listener{
|
|
// The instrument: these belong on the reserved addresses as much as anywhere.
|
|
{Name: "control-listen", Spec: c.ControlListen, Measurement: true},
|
|
{Name: "udp-listen", Spec: c.UDPListen, Measurement: true},
|
|
{Name: "tcp-listen", Spec: c.TCPListen, Measurement: true},
|
|
{Name: "dns-listen", Spec: c.DNSListen, Measurement: true},
|
|
{Name: "stun-listen", Spec: c.StunListen, Measurement: true},
|
|
// http-echo is deliberately not marked as measurement: it is cleartext HTTP, so on a
|
|
// reserved address it would be the very listener that ruins the port-80 test.
|
|
{Name: "http-echo-listen", Spec: c.HTTPEchoListen},
|
|
// Services. These have no business on an address kept for measuring.
|
|
{Name: "admin-listen", Spec: c.AdminListen},
|
|
{Name: "acme-http-listen", Spec: c.ACMEHTTPListen},
|
|
}
|
|
}
|
|
|
|
// MeasurementAddrs picks out the addresses this server can be measured on, by family, splitting
|
|
// primaries from the reserved alternates.
|
|
//
|
|
// Derived from what is actually bound rather than configured separately: a second list of the
|
|
// server's own addresses is a second thing to keep in step, and the copy that drifts is the one
|
|
// clients are told about.
|
|
func (c *Config) MeasurementAddrs() (ip4, ip6, ip4Alt, ip6Alt string) {
|
|
reserved := map[string]bool{}
|
|
for _, ip := range c.ReservedIPs() {
|
|
reserved[ip.String()] = true
|
|
}
|
|
// The UDP data plane binds every address a client may be pointed at, which makes it the
|
|
// honest source for this.
|
|
for _, a := range Addrs(c.UDPListen) {
|
|
host, _, err := net.SplitHostPort(a)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
host = strings.Trim(host, "[]")
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
alt := reserved[ip.String()]
|
|
switch {
|
|
case ip.To4() != nil && alt && ip4Alt == "":
|
|
ip4Alt = host
|
|
case ip.To4() != nil && !alt && ip4 == "":
|
|
ip4 = host
|
|
case ip.To4() == nil && alt && ip6Alt == "":
|
|
ip6Alt = host
|
|
case ip.To4() == nil && !alt && ip6 == "":
|
|
ip6 = host
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Addrs splits a comma-separated listen spec into individual addresses.
|
|
// Explicit per-address binds matter on multi-IP hosts: a wildcard bind
|
|
// (":8443") would also claim addresses reserved for other purposes (e.g. an
|
|
// SSH-only management IP).
|
|
func Addrs(spec string) []string {
|
|
var out []string
|
|
for _, a := range strings.Split(spec, ",") {
|
|
if a = strings.TrimSpace(a); a != "" {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Actions are the verbs. Serving is one of them, and it is explicit: running the binary with no
|
|
// arguments prints usage rather than binding a dozen ports and starting to answer the internet.
|
|
// Someone typing the name of an unfamiliar program on a terminal should be told what it does, not
|
|
// have it start doing it.
|
|
type Actions struct {
|
|
Serve bool
|
|
// Help is set when there is nothing to do: no verb was given.
|
|
Help bool
|
|
|
|
InstallSystemd bool
|
|
UninstallSystemd bool
|
|
SelfUpdate bool
|
|
SetAdminPassword bool
|
|
// MintEnrollToken is the note to record against a freshly minted enrollment link.
|
|
//
|
|
// A local action rather than an HTTP endpoint: whoever can run this binary against the state
|
|
// directory already has every privilege the server has, so authenticating them to themselves
|
|
// would be theatre — and an unauthenticated endpoint on loopback is how the admin API was
|
|
// briefly reachable from the network by accident.
|
|
MintEnrollToken string
|
|
Version bool
|
|
}
|
|
|
|
func defaultStateDir() string {
|
|
if os.Geteuid() == 0 {
|
|
return "/var/lib/echolot-server"
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "./echolot-state"
|
|
}
|
|
return home + "/.local/share/echolot-server"
|
|
}
|
|
|
|
// inContainer detects Docker/Podman/K8s without being asked: /.dockerenv,
|
|
// /run/.containerenv (podman), or a container hint in /proc/1/cgroup
|
|
// (cgroup v1 era) / KUBERNETES_SERVICE_HOST. Best-effort — --docker and
|
|
// ECHOLOT_DOCKER always win.
|
|
func inContainer() bool {
|
|
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
|
|
if _, err := os.Stat(marker); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
|
|
return true
|
|
}
|
|
if b, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
|
s := string(b)
|
|
if strings.Contains(s, "docker") || strings.Contains(s, "containerd") || strings.Contains(s, "kubepods") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|