gaterun

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 7, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

Documentation

Overview

Package gaterun is the per-session egress gate's runtime. On startup it applies the owner-match firewall, verifies it, then drops privileges, and thereafter serves internal/gate's forward proxy fed by a periodic fetch of the session's config from the control plane's internal gate-config endpoint (docs/plan/12_vaults-credentials.md slice 4).

The OS-touching adapters — the real iptables application and the privilege drop — live in cmd/gate behind the Firewall and PrivDropper seams declared here, so this package stays unit-testable off a real container. Everything with logic lives here and is tested: config conversion, the hot-swap handler, the fetch/swap loop, the firewall rule set, and its post-apply verification.

Index

Constants

View Source
const ChainName = "MAP-GATE-EGRESS"

ChainName is the filter-table chain the gate owns outright. The owner-match rules live here — not inlined into OUTPUT — so the gate can coexist with a netns whose OUTPUT chain already carries foreign rules (a service mesh's redirects in a Kubernetes pod, a CNI's filters): OUTPUT gets exactly one jump into this chain, inserted first, and everything else in OUTPUT is left alone. Every verdict in the chain is terminal (ACCEPT or DROP, nothing RETURNs), so with the jump first no packet ever reaches a foreign OUTPUT rule — the policy semantics are identical to owning the whole chain.

View Source
const DefaultGateUID = 65532

DefaultGateUID is the uid the gate drops to and the owner-match firewall ACCEPTs (cmd/gate's GATE_UID default). It lives here rather than in cmd/gate because the sandbox providers need it too: a sandbox running as this uid matches the ACCEPT rule and leaves the namespace unfiltered, so they refuse that combination rather than start a sandbox whose egress policy is silently void (#65, #196).

View Source
const DefaultProxyAddr = "127.0.0.1:15080"

DefaultProxyAddr is the loopback address the gate's egress proxy listens on, and the address the sandbox reaches it at — the two share a network namespace, so the sandbox's localhost is the gate's. It is the one contract between the two ends: cmd/gate binds GATE_ADDR here by default, and the executor points the sandbox's HTTP(S)_PROXY at "http://" + this, so a single constant keeps the listener and the client from drifting apart.

Variables

This section is empty.

Functions

func AppendedRules

func AppendedRules(listing, chain string) [][]string

AppendedRules extracts the `-A <chain>` rules of one `iptables -S` listing, in order, as their token slices (the arguments after "-A <chain>"). Shared with the cmd/gate adapter, which parses OUTPUT the same way to reconcile the jump position — one parser, so the two can never disagree on what a rule is.

func CheckGateID

func CheckGateID(name string, id int) error

CheckGateID rejects a uid/gid that is not a positive non-root value fitting uid_t/gid_t. Zero is root (a Setuid/Setgid(0) drop is a silent no-op); a value past maxGateID would truncate in the syscall and could land on root. name labels the error for the caller (e.g. "GATE_UID").

func CheckListing

func CheckListing(l Listing, gateUID int) error

CheckListing verifies one family took the owner-match policy, in two halves. (a) The gate's own chain is EXACTLY Ruleset(gateUID) and nothing else: the ordered `-A MAP-GATE-EGRESS` rules must equal it token-for-token. Exactness is the security property — a relative-order or substring check is not enough: a foreign ACCEPT before the DROP would leave egress fail-open (first-match-wins) yet still contain our three rules in order, and a rule that only resembles ours changes the firewall's meaning while passing a loose match — `--uid-owner 1000` when the gate is uid 100, `-o lo0` or `! -o lo` for the loopback rule, or an extra match clause. (b) The FIRST appended OUTPUT rule is exactly the jump into the chain: a foreign rule above the jump would decide traffic before the policy sees it (fail-open for an ACCEPT), while foreign rules BELOW the jump are tolerated — every chain verdict is terminal, so they are unreachable — which is what lets the gate coexist with CNI/mesh rules instead of flushing them. It is the post-apply gate that aborts startup when the firewall did not take. `-P` policy and `-N` chain declaration lines are ignored: the verified explicit catch-all DROP makes the steady state fail-closed by itself, whatever the policies say.

func CheckLoopbackListenAddr

func CheckLoopbackListenAddr(addr string) error

CheckLoopbackListenAddr rejects a proxy listen address whose host is not loopback. The gate's forward proxy is unauthenticated — its only protection is that only the co-resident sandbox, sharing its network namespace, can reach it over loopback. Binding it to a routable address (":15080" binds every interface, "0.0.0.0:15080", a LAN IP) would expose a credential-substituting egress proxy to whatever else can route to that address, so the gate refuses to start on one. Only a loopback IP (127.0.0.0/8, ::1) or the literal "localhost" is accepted.

func Convert

func Convert(cfg *gateconfig.Config) gate.Config

Convert builds a gate.Config from a fetched gateconfig.Config: the environment networking policy passes through unchanged, and each resolved credential becomes an egress.Credential — its allowed_hosts a HostSet, its unrestricted arm carried through so the secret substitutes for any host. OnUnreachable is left unset — the seam is diagnostic-only, and credential_host_unreachable_error is a config-conflict event the controlplane emits when rendering this config, not something the gate reports; Dial/Transport/MaxBodyBytes take gate.New's defaults.

func RunFetchLoop

func RunFetchLoop(ctx context.Context, f Fetcher, handler *SwappableHandler, interval time.Duration) error

RunFetchLoop keeps handler's gate current for the session's life: it fetches once immediately, then every interval, converting each config into a fresh gate and swapping it in atomically.

The two error postures are the revocation contract (docs/plan/12 slice 4c-2b):

  • gateconfig.ErrUnauthorized — the token was revoked or the session archived — is terminal. The loop swaps in a deny-all gate and returns the error, so the caller shuts the server down: an unauthorized gate stops serving (fail-closed), and a 401 is the one unambiguous "revoked" signal.
  • any other error is transient (a control-plane blip or network hiccup): the loop keeps the last-known-good gate and retries next tick, so a momentary outage — even one longer than any TTL — never cuts a live session's egress.

It returns nil when ctx is cancelled.

func Setup

func Setup(ctx context.Context, fw Firewall, pd PrivDropper, gateUID int) error

Setup applies the owner-match firewall, verifies it took on both IP tables, then drops privileges — the startup order the gate's entrypoint runs before it begins serving (so the HEALTHCHECK that gates admission cannot pass until the firewall is in force). A verification failure aborts startup fail-closed: the gate never serves on a firewall that did not take.

gateUID must be a positive non-root uid (see CheckGateID): dropping to uid 0 is a silent no-op (the process stays root, keeps CAP_NET_ADMIN, and can still rewrite the chain), so an invalid gateUID is refused here rather than serving as un-dropped root.

Types

type Fetcher

type Fetcher interface {
	Fetch(ctx context.Context) (*gateconfig.Config, error)
}

Fetcher retrieves the session's current gate config from the control plane. *gateconfig.Client satisfies it.

type Firewall

type Firewall interface {
	Apply(ctx context.Context, rules []Rule) error
	List(ctx context.Context) (v4, v6 Listing, err error)
}

Firewall is the OS firewall the gate applies on startup. Apply RECONCILES on both the IPv4 and IPv6 tables: it (re)builds ChainName to exactly these rules atomically, then ensures a single jump into it sits first in OUTPUT — never flushing OUTPUT, never touching its policy, so foreign rules a CNI or service mesh installed in a shared pod netns survive below the jump (where the terminal chain verdicts make them unreachable). Ordering is the fail-closed guarantee: the chain is complete before any jump steers traffic into it, so there is no instant where a partial policy is live, and a re-apply over a previous incarnation's rules (a restarted sidecar in a live pod) is a no-op rather than a duplicate. List returns each family's chain and OUTPUT listings for the post-apply verification. The real adapter (iptables/ip6tables + iptables-restore via os/exec) lives in cmd/gate; tests supply a fake.

type Listing

type Listing struct {
	Chain  string // `-S MAP-GATE-EGRESS`
	Output string // `-S OUTPUT`
}

Listing is one family's firewall state as read back for verification: the gate's own chain and the OUTPUT chain, each in `iptables -S <chain>` form.

type PrivDropper

type PrivDropper interface {
	Drop() error
}

PrivDropper drops the process to the gate's unprivileged UID/GID after the firewall is applied — so the gate can no longer alter the rules, and its own sockets carry the owner-match UID. The real adapter (setgroups/setgid/setuid) lives in cmd/gate; tests supply a fake.

type Rule

type Rule []string

Rule is one rule in the gate's own firewall chain (ChainName), expressed as the arguments that follow "-A MAP-GATE-EGRESS". The same rules apply to both the IPv4 (iptables) and IPv6 (ip6tables) tables — the adapter owns that duplication.

func Ruleset

func Ruleset(gateUID int) []Rule

Ruleset is the gate's owner-match policy — the contents of ChainName — in evaluation order. iptables is first-match-wins, so the two ACCEPTs precede the catch-all DROP:

  1. all loopback traffic — the sandbox reaches the proxy, and curls its own localhost dev servers, over lo (the operator-approved intra-netns loopback width, docs/plan/12 slice 4c-2b decision);
  2. the gate process's own egress, matched by its post-privdrop UID (the owner-match — this is why the gate drops to a dedicated UID);
  3. everything else — the sandbox's own UID reaching a non-loopback address — is dropped, so the sandbox can leave the netns only through the proxy, which then egresses as the gate UID.

The owner-match only isolates the sandbox if the sandbox cannot become the gate's uid: it must run as a distinct non-root identity and be unable to change uid (drop CAP_SETUID/CAP_SETGID, no-new-privileges), otherwise a tool could setuid to the gate uid and egress directly. It must also CapDrop NET_RAW (an AF_PACKET socket bypasses the netfilter OUTPUT hook, defeating owner-match). Both are the sandbox's provisioning concern, enforced in the Docker/K8s wiring (STATE, sub-PR 4), not here.

type SwappableHandler

type SwappableHandler struct {
	// contains filtered or unexported fields
}

SwappableHandler is the stable http.Handler the gate's single http.Server serves for its whole life: every request is forwarded to the current *gate.Gate, which the fetch loop replaces atomically as the session's config changes. The server never restarts across a swap, and a request already in flight keeps the gate it started on. Before the first successful fetch the handler serves whatever gate it was constructed with — the caller passes a fail-closed gate (admits nothing, no credentials) so an unconfigured gate denies rather than leaks.

func NewSwappableHandler

func NewSwappableHandler(initial *gate.Gate) *SwappableHandler

NewSwappableHandler returns a handler that serves initial until Swap replaces it. initial must be non-nil.

func (*SwappableHandler) ServeHTTP

func (h *SwappableHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP forwards to the current gate under an egress span. The span is made the active span for the forwarded request (so it wraps the whole proxied request or CONNECT tunnel and can parent any spans the gate emits), carrying the method and target host — never a credential, substitution happens inside the gate. The gate forwards through a plain transport that does not inject a traceparent, so making the span active does not leak our trace context to the third-party origin; that boundary is deliberate.

func (*SwappableHandler) Swap

func (h *SwappableHandler) Swap(g *gate.Gate)

Swap installs g as the gate every subsequent request uses.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL