The control plane now advertises the same name the web UI answers on. That is safe because the client authenticates by SPKI pin and explicitly does not verify the hostname — "pin is the trust, not the name" — so no certificate covers or needs to cover either name. The per-host name still means something, though, and the rule it encodes has to survive: pinning binds a client to one server's key, so fmr may be a CNAME to exactly one host and never a multi-address service record. A second server gets enrolled as fmr-2 explicitly, because a client that reaches a different key does not fail over, it fails. Minting a link was broken and had been since the authenticated admin UI replaced the old admin API: enroll-link.sh still posted to 127.0.0.1:8444/admin/enroll-tokens, an endpoint that no longer exists on a listener that no longer binds loopback. Rather than add a second unauthenticated door — which is how the old one ended up briefly reachable from the network — the binary mints its own link. Whoever can run it against the state directory already holds every privilege the server has, so authenticating them to themselves would be theatre. EnrollmentURI is shared with the running server's EnrollmentLink rather than reimplemented. Two copies of that encoding would eventually disagree, and the failure mode is a pin that looks right and surfaces as an inscrutable TLS error rather than as a bad pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
380 lines
20 KiB
Go
380 lines
20 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
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
// 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.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.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.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},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|