Skip to content

Commit d401458

Browse files
author
gluck
committed
gateway: the refusal table, judged against the socket the kernel bound
Step 3. Which authenticator may serve which exposure is now a table with all nine cells enumerated and tested, plus a totality test that fails when someone adds an authenticator without deciding what its cells mean. THE ADDRESS IS NOT JUDGED AS A STRING. ":9090", "0.0.0.0", "::", "::ffff:127.0.0.1", a hostname resolving to a LAN address and a localhost repointed in /etc/hosts all defeat a config parse. So Serve binds FIRST and interrogates net.Listener.Addr(), which is the only thing that cannot lie about itself. A listener that fails admit() is closed, not served: a server that refuses while holding the port has refused nothing. none unix only -- anyone reaching the port is an administrator upstream unix, loopback -- Remote-* is believable only behind a proxy doorkey anywhere -- carries proof per request; plaintext TCP still needs --tls-terminated Loopback is documented as what it is: not a principal boundary. Every local uid reaches it, a published container port or an ssh -L re-exposes it. It buys "the proxy can be the only route IF the box is not shared", and where that is not enough the answer is a credential, not a tighter address check. TWO CONTROLS FROM THE KING OF SPAIN, who reviewed the deployment: Cloudflare reaps an idle WebSocket at ~100s and it is not configurable on a tunnel, so the tunnel now pings every 30s. This had to ship WITH the feature: an idle-only failure never appears in a test that keeps sending, and appears immediately when the operator stops typing. More seriously: forward_auth runs on the UPGRADE REQUEST ONLY. Once a socket is established no frame is ever re-authorized, so a revoked operator keeps a live shell until it drops -- a WebSocket outlives its authorization. Tunnels are now capped (8h by default on TCP), because a re-upgrade is a re-authorization. "Authelia gates it" must not stand unqualified in this design: what it gates is the handshake. Also: a Host allowlist, because binding loopback is no defence against DNS rebinding from a browser on the same machine; and --doorkey-file rather than a flag, since an argument is visible in /proc and in every other user's process table, while a file is a path plus a mode -- and is what LoadCredential hands you in production. The plan's section 7 is rewritten with the King's answers: dedicated user rather than DynamicUser (a figwal store's ownership must survive redeploys), KillMode=mixed, MemoryMax/CPUQuota because spain is the house router, one figaro-admin group rather than split capabilities (a read-only figaro role would be a lie), and hush left off the server entirely -- machine secrets belong in sops. It also records the sequencing constraint: the parked router branch means this must be a single-purpose watched deploy, never merged to sit in spain-flake master. Full suite green.
1 parent 5d26496 commit d401458

7 files changed

Lines changed: 794 additions & 88 deletions

File tree

‎internal/cli/cli.go‎

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,33 +1414,66 @@ flow writes both through the daemon, so a client never has to know the path.`,
14141414
Long: `Serves two things on one listener: a tunnel at /v1/socket that carries the
14151415
whole JSON-RPC surface unchanged, and a health endpoint at /v1/health.
14161416
1417-
The listener is a UNIX SOCKET. A tcp:// address is refused rather than
1418-
quietly downgraded: the authenticators and bind-address checks that would
1419-
make a port safe are not built yet, and a unix socket inherits the same
1420-
"you had to be me to reach it" argument the angelus socket already rests on.
1421-
Put Caddy (or any reverse proxy) in front of the socket to reach it from a
1422-
network.
1417+
WHICH AUTHENTICATOR MAY SERVE WHICH ADDRESS is a table, and it is checked
1418+
against the address the kernel ACTUALLY bound -- not the string you typed,
1419+
because ":9090", "0.0.0.0" and "::ffff:127.0.0.1" all defeat a config parse.
1420+
1421+
authn=none unix socket only. Anyone who reaches the port is an admin.
1422+
authn=upstream unix or loopback. Trusts Remote-User/Remote-Groups, which
1423+
only a reverse proxy on the same box should be able to set.
1424+
authn=doorkey anywhere, but plaintext TCP needs --tls-terminated.
1425+
1426+
A refused combination does not start. Loopback is not a principal boundary:
1427+
every local uid reaches it, and a published container port or an ssh -L tunnel
1428+
re-exposes it. Where that is not enough the answer is a credential.
14231429
14241430
Browser origins are DENIED by default. CORS does not apply to WebSocket
14251431
upgrades, so a page carrying an ambient session cookie could otherwise open
1426-
a tunnel and speak raw JSON-RPC; --origin is the opt-in.
1432+
a tunnel and speak raw JSON-RPC; --origin is the opt-in. On any TCP bind set
1433+
--host too, or a browser reaches you by DNS rebinding.
1434+
1435+
TUNNELS ARE CAPPED (8h by default on TCP). Forward authentication happens on
1436+
the upgrade request only: once a WebSocket is established no frame is ever
1437+
re-authorized, so a revoked operator keeps a live shell until the socket
1438+
drops. The cap forces a re-upgrade, and a re-upgrade is re-authorized.
14271439
14281440
serve does not start the daemon. It fronts one that is already running.`,
1429-
Usage: "serve [--listen unix:///path] [--origin <url>[,<url>]]",
1441+
Usage: "serve [--listen <uri>] [--authn <n>] [--doorkey-file <p>] [--origin <urls>] [--host <names>]",
14301442
Flags: []cmdkit.FlagDef{
1431-
{Long: "listen", Description: "unix:///path to bind (default: gateway.sock beside the angelus socket)"},
1443+
{Long: "listen", Description: "unix:///path or tcp://host:port (default: gateway.sock beside the angelus socket)"},
1444+
{Long: "authn", Default: "none", Description: "none | upstream | doorkey"},
1445+
{Long: "doorkey-file", Description: "File holding the shared secret for --authn doorkey"},
14321446
{Long: "origin", Description: "Comma-separated browser origins allowed to open a tunnel"},
1447+
{Long: "host", Description: "Comma-separated Host: header allowlist (set this on any TCP bind)"},
1448+
{Long: "tls-terminated", IsBool: true, Description: "Assert that something in front terminates TLS"},
1449+
{Long: "max-conn-age", Description: "Cap one tunnel's lifetime, e.g. 8h. Forces re-authorization (default 8h on TCP)"},
14331450
},
14341451
Run: func(ctx *cmdkit.RunContext) error {
1435-
var origins []string
1436-
if o := ctx.Flag("origin"); o != "" {
1437-
for _, s := range strings.Split(o, ",") {
1452+
split := func(v string) []string {
1453+
var out []string
1454+
for _, s := range strings.Split(v, ",") {
14381455
if s = strings.TrimSpace(s); s != "" {
1439-
origins = append(origins, s)
1456+
out = append(out, s)
14401457
}
14411458
}
1459+
return out
1460+
}
1461+
o := ServeOpts{
1462+
Listen: ctx.Flag("listen"),
1463+
Authn: ctx.Flag("authn"),
1464+
DoorkeyFile: ctx.Flag("doorkey-file"),
1465+
Origins: split(ctx.Flag("origin")),
1466+
Hosts: split(ctx.Flag("host")),
1467+
TLSDone: ctx.BoolFlag("tls-terminated"),
1468+
}
1469+
if v := ctx.Flag("max-conn-age"); v != "" {
1470+
d, err := time.ParseDuration(v)
1471+
if err != nil {
1472+
return fmt.Errorf("--max-conn-age: %w", err)
1473+
}
1474+
o.MaxConnAge = d
14421475
}
1443-
return runServe(mustLoadConfig(), ctx.Flag("listen"), origins)
1476+
return runServe(mustLoadConfig(), o)
14441477
},
14451478
})
14461479

‎internal/cli/serve.go‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import (
1313
"os"
1414
"os/signal"
1515
"path/filepath"
16+
"strings"
1617
"syscall"
18+
"time"
1719

1820
"github.com/jack-work/figaro/internal/config"
1921
"github.com/jack-work/figaro/internal/gateway"
@@ -25,11 +27,46 @@ func defaultGatewaySocket() string {
2527
return filepath.Join(angelusRuntimeDir(), "gateway.sock")
2628
}
2729

28-
func runServe(loaded *config.Loaded, listen string, origins []string) error {
30+
// ServeOpts is what the verb collected from the command line.
31+
type ServeOpts struct {
32+
Listen string
33+
Authn string
34+
DoorkeyFile string
35+
Origins []string
36+
Hosts []string
37+
TLSDone bool
38+
MaxConnAge time.Duration
39+
}
40+
41+
func runServe(loaded *config.Loaded, o ServeOpts) error {
42+
listen := o.Listen
2943
if listen == "" {
3044
listen = "unix://" + defaultGatewaySocket()
3145
}
3246

47+
// An uncapped tunnel on a NETWORK bind is an unbounded authorization:
48+
// forward_auth runs on the upgrade only, so the socket outlives the
49+
// session that opened it. Eight hours is a working day, and a re-upgrade
50+
// is a re-authorization. A unix socket needs no cap -- reaching it
51+
// already required being the right uid.
52+
if o.MaxConnAge == 0 && strings.HasPrefix(listen, "tcp://") {
53+
o.MaxConnAge = 8 * time.Hour
54+
}
55+
56+
// THE SECRET COMES FROM A FILE, never a flag. An argument is visible in
57+
// /proc, in a shell history, and in the process table of every other
58+
// user on the box; a file is a path plus a mode. It is also what
59+
// systemd LoadCredential hands you, which is how this arrives in
60+
// production.
61+
var doorkey string
62+
if o.DoorkeyFile != "" {
63+
b, err := os.ReadFile(o.DoorkeyFile)
64+
if err != nil {
65+
return fmt.Errorf("doorkey file: %w", err)
66+
}
67+
doorkey = strings.TrimSpace(string(b))
68+
}
69+
3370
// The gateway fronts a LOCAL angelus, so one must be running. We do not
3471
// start it: ensureAngelus detaches a grandchild, which under systemd
3572
// Type=exec escapes the unit's lifecycle and leaves an orphan the
@@ -47,7 +84,12 @@ func runServe(loaded *config.Loaded, listen string, origins []string) error {
4784
srv, err := gateway.New(gateway.Config{
4885
Listen: listen,
4986
AngelusSocket: sock,
50-
Origins: origins,
87+
Authn: gateway.Authn(o.Authn),
88+
Doorkey: doorkey,
89+
Origins: o.Origins,
90+
Hosts: o.Hosts,
91+
TLSTerminated: o.TLSDone,
92+
MaxConnAge: o.MaxConnAge,
5193
})
5294
if err != nil {
5395
return err
@@ -57,7 +99,7 @@ func runServe(loaded *config.Loaded, listen string, origins []string) error {
5799
defer stop()
58100

59101
fmt.Fprintf(stderrw, "figaro gateway on %s -> %s\n", listen, sock)
60-
if len(origins) == 0 {
102+
if len(o.Origins) == 0 {
61103
// Say it out loud. An empty allowlist is the SAFE default, but it is
62104
// also the one that makes a browser client fail with a handshake
63105
// error that says nothing useful, so the operator should hear about

‎internal/gateway/refusal.go‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package gateway
2+
3+
// THE REFUSAL TABLE.
4+
//
5+
// Every cell of (authenticator × bind reachability), decided once, here.
6+
// The rule this file exists to enforce: a combination that is an
7+
// authentication bypass must be UNREPRESENTABLE, not merely discouraged.
8+
//
9+
// Two lessons are baked in, both from adversarial review of the plan:
10+
//
11+
// 1. THE ADDRESS CANNOT BE JUDGED AS A STRING. ":9090", "0.0.0.0", "::",
12+
// "[::1]", "::ffff:127.0.0.1", a hostname that resolves to a LAN
13+
// address, and `localhost` repointed in /etc/hosts all defeat a config
14+
// parse. So we BIND FIRST and interrogate the listener's resolved
15+
// address, which is the only thing that cannot lie about itself.
16+
//
17+
// 2. LOOPBACK IS NOT A PRINCIPAL BOUNDARY. Any local uid reaches it, a
18+
// container with host networking or a published port re-exposes it, and
19+
// `ssh -L` tunnels it from anywhere. Loopback earns you "the reverse
20+
// proxy can be the only route IF the box is not shared" and nothing
21+
// more. Where that is not enough, the answer is a credential, not a
22+
// tighter address check -- and the table says so.
23+
24+
import (
25+
"fmt"
26+
"net"
27+
"strings"
28+
)
29+
30+
// Authn names an authenticator. A closed set: an unknown value is refused
31+
// rather than defaulted, for the same reason an unknown authz policy is.
32+
type Authn string
33+
34+
const (
35+
// AuthnNone trusts everyone who can reach the socket. Legitimate for a
36+
// unix socket, where reaching it already required being the right uid.
37+
AuthnNone Authn = "none"
38+
// AuthnUpstream believes Remote-User / Remote-Groups, which only a
39+
// reverse proxy should be able to set.
40+
AuthnUpstream Authn = "upstream"
41+
// AuthnDoorkey requires a shared bearer secret.
42+
AuthnDoorkey Authn = "doorkey"
43+
)
44+
45+
// KnownAuthn is the closed set, for validation and for error prose.
46+
var KnownAuthn = []Authn{AuthnNone, AuthnUpstream, AuthnDoorkey}
47+
48+
// reach is how exposed a bound address turned out to be.
49+
type reach int
50+
51+
const (
52+
// reachUnix: a filesystem socket. The kernel's permission check is the
53+
// gate, and it is a real one.
54+
reachUnix reach = iota
55+
// reachLoopback: bound to 127.0.0.0/8 or ::1. Reachable by every local
56+
// uid, and by anything that can publish or tunnel the port.
57+
reachLoopback
58+
// reachOpen: bound to a routable or wildcard address. On a network.
59+
reachOpen
60+
)
61+
62+
func (r reach) String() string {
63+
switch r {
64+
case reachUnix:
65+
return "a unix socket"
66+
case reachLoopback:
67+
return "loopback"
68+
default:
69+
return "a routable address"
70+
}
71+
}
72+
73+
// classify interrogates a BOUND listener. Nothing here parses a config
74+
// string, which is the whole point: net.Listener.Addr() reports what the
75+
// kernel actually did, including what a wildcard bind resolved to and which
76+
// family an ambiguous host landed in.
77+
func classify(ln net.Listener) reach {
78+
addr := ln.Addr()
79+
if addr.Network() == "unix" || addr.Network() == "unixpacket" {
80+
return reachUnix
81+
}
82+
host, _, err := net.SplitHostPort(addr.String())
83+
if err != nil {
84+
// Cannot tell: assume the worst. An address we cannot parse is not
85+
// an address we may call safe.
86+
return reachOpen
87+
}
88+
ip := net.ParseIP(host)
89+
if ip == nil {
90+
return reachOpen
91+
}
92+
if ip.IsUnspecified() {
93+
// 0.0.0.0 or ::. Bound to EVERY interface, including the routable
94+
// ones. This is the case a naive "is it 127.0.0.1?" check misses.
95+
return reachOpen
96+
}
97+
// IsLoopback handles ::1 and the v4-mapped ::ffff:127.0.0.1 correctly,
98+
// which is why we ask the parsed IP rather than the string.
99+
if ip.IsLoopback() {
100+
return reachLoopback
101+
}
102+
return reachOpen
103+
}
104+
105+
// admit is the table. It answers one question -- may this authenticator
106+
// serve on an address this exposed -- and it is the only place that answers
107+
// it.
108+
//
109+
// The shape to read off it: `none` needs the kernel; `upstream` needs to be
110+
// unreachable except through the proxy; `doorkey` carries its own proof and
111+
// so is the only cell that survives exposure, and even then only with TLS
112+
// terminated in front.
113+
func admit(a Authn, r reach) error {
114+
switch a {
115+
case AuthnNone:
116+
if r == reachUnix {
117+
return nil
118+
}
119+
return fmt.Errorf(
120+
"authn=none on %s is an open door to a daemon that runs shell commands.\n"+
121+
"Anyone who can reach the port is an administrator.\n"+
122+
"Bind a unix socket, or choose an authenticator", r)
123+
124+
case AuthnUpstream:
125+
switch r {
126+
case reachUnix:
127+
return nil
128+
case reachLoopback:
129+
// The house pattern: a reverse proxy on the same box strips
130+
// inbound Remote-* and forward-auths. Sound only while nothing
131+
// ELSE local is hostile -- see the doc comment on Config.
132+
return nil
133+
default:
134+
return fmt.Errorf(
135+
"authn=upstream trusts Remote-* headers, but %s is reachable off-host.\n"+
136+
"Anyone who can reach it can claim any identity, including admin.\n"+
137+
"Bind loopback or a unix socket and let the proxy reach you there", r)
138+
}
139+
140+
case AuthnDoorkey:
141+
// A bearer proves something on every request, so exposure is a
142+
// transport-confidentiality question rather than an authentication
143+
// one. Config.Check gates the plaintext case separately.
144+
return nil
145+
146+
default:
147+
names := make([]string, len(KnownAuthn))
148+
for i, k := range KnownAuthn {
149+
names[i] = string(k)
150+
}
151+
return fmt.Errorf("unknown authn %q: want one of %s", a, strings.Join(names, ", "))
152+
}
153+
}

0 commit comments

Comments
 (0)