diff --git a/internal/cfsetup/cfsetup.go b/internal/cfsetup/cfsetup.go new file mode 100644 index 0000000..c71eafa --- /dev/null +++ b/internal/cfsetup/cfsetup.go @@ -0,0 +1,448 @@ +// Package cfsetup builds the RECOMMENDED, one-click Cloudflare Tunnel + Access +// configuration the felis breakGlass TUI can offer a SysAdmin (spec §14 Zero +// Trust edge). It is deliberately "锦上添花" — icing, not a mandate: the platform +// is domain-agnostic (every FQDN is composed from the configured root_domain) and +// IdP-agnostic (felis-api validates ANY valid Cloudflare Access JWT `aud`, no +// matter which identity provider — Google Workspace, Keycloak, Microsoft Entra — +// fronts it). A SysAdmin who brings their own domain or a different Zero-Trust +// scheme is fully supported; this package only makes the common case easy. +// +// The split is honest about what this box can verify: +// +// - PURE + UNIT-VERIFIED here: the ingress-config generation, the Access +// application/policy request bodies, the gating preconditions, and — the one +// load-bearing safety property — the FAIL-CLOSED guard on the recommended +// policy (it must never serialize to public/allow-everyone or skip auth). +// - INTEGRATION-ONLY (see runner.go): actually creating the tunnel, routing +// DNS, and POSTing the Access app/policy. Those require the operator's OWN +// live Cloudflare account and the interactive `cloudflared tunnel login` +// browser consent, which this package can neither perform nor fake. +// +// Setup wires the two together behind a Runner interface so the orchestration is +// testable with a fake while the real exec/HTTP impl stays integration-only. +package cfsetup + +import ( + "context" + "errors" + "fmt" + "strings" + + "sigs.k8s.io/yaml" +) + +// defaultPanelOrigin is where the tunnel forwards the web hostnames when the +// caller does not override it: the felis-api listen port (config defaultListen +// is 0.0.0.0:8080), reachable on the box as loopback. +const defaultPanelOrigin = "http://localhost:8080" + +// defaultSessionDuration is the recommended Access session length when unset. +const defaultSessionDuration = "24h" + +// catchAllService is the cloudflared sentinel that returns a bare 404 for any +// hostname not explicitly routed. cloudflared REQUIRES the final ingress rule to +// be a hostname-less catch-all; we always make it this fail-shut 404 so the +// tunnel never forwards an unexpected Host to the origin. +const catchAllService = "http_status:404" + +// Gating errors — Setup refuses (with NO side effects) unless every precondition +// the operator alone can satisfy is met. The TUI surfaces these as remediation. +var ( + // ErrCloudflaredMissing means the cloudflared binary is not on PATH. + ErrCloudflaredMissing = errors.New("cfsetup: cloudflared binary not found on PATH — install cloudflared first") + // ErrNotLoggedIn means ~/.cloudflared/cert.pem is absent: the operator has + // not run `cloudflared tunnel login`. That step is an interactive browser + // consent against the operator's OWN Cloudflare account; the TUI cannot and + // must not bypass it. + ErrNotLoggedIn = errors.New("cfsetup: not logged in to Cloudflare — run `cloudflared tunnel login` first (browser consent on your own account)") + // ErrNoAPIToken means no Cloudflare API token was supplied for the Access + // application/policy calls. + ErrNoAPIToken = errors.New("cfsetup: a Cloudflare API token is required to configure Access") +) + +// AccessIdentity scopes WHO the recommended Access policy admits. At least one +// field must be set — an empty identity is refused as fail-open. The three +// dimensions map straight onto Cloudflare Access rule types and cover the +// SSO-provider case the SysAdmin may want (Google Workspace / Keycloak / Entra +// are registered in Access as IdPs; LoginMethods names their IdP ids): +// +// - Emails → include {email:{email}} (specific people) +// - EmailDomains → include {email_domain:{domain}} (an org's SSO domain) +// - LoginMethods → require {login_method:{id}} (only this IdP/SSO) +type AccessIdentity struct { + Emails []string + EmailDomains []string + LoginMethods []string +} + +func (id AccessIdentity) empty() bool { + return len(id.Emails) == 0 && len(id.EmailDomains) == 0 && len(id.LoginMethods) == 0 +} + +// accessRule is one Cloudflare Access rule object, e.g. {"email":{"email":...}} +// or {"everyone":{}}. Modeled as a map so the include/require/exclude arrays +// serialize to exactly the shapes the Access API expects. +type accessRule map[string]map[string]any + +// AccessPolicy is the request body for an Access policy (decision + the +// include/require/exclude rule arrays, which compose as OR / AND / NOT). +type AccessPolicy struct { + Name string `json:"name"` + Decision string `json:"decision"` + Include []accessRule `json:"include"` + Require []accessRule `json:"require,omitempty"` + Exclude []accessRule `json:"exclude,omitempty"` +} + +// AccessApplication is the request body for a self-hosted Access application +// fronting one web hostname (spec §14: the op.console SysAdmin face). The cookie +// hardening defaults are on because this guards the most privileged surface. +type AccessApplication struct { + Name string `json:"name"` + Domain string `json:"domain"` + Type string `json:"type"` + SessionDuration string `json:"session_duration,omitempty"` + AllowedIdPs []string `json:"allowed_idps,omitempty"` + AppLauncherVisible bool `json:"app_launcher_visible"` + EnableBindingCookie bool `json:"enable_binding_cookie"` + HTTPOnlyCookieAttribute bool `json:"http_only_cookie_attribute"` +} + +// BuildAccessApplication assembles a self-hosted Access application for one +// hostname. allowedIdPs, when non-empty, restricts which configured IdPs (the +// SysAdmin's chosen SSO) may satisfy the app — left empty, Access offers all +// configured IdPs. +func BuildAccessApplication(domain, name string, sessionDuration string, allowedIdPs []string) AccessApplication { + if sessionDuration == "" { + sessionDuration = defaultSessionDuration + } + return AccessApplication{ + Name: name, + Domain: domain, + Type: "self_hosted", + SessionDuration: sessionDuration, + AllowedIdPs: allowedIdPs, + AppLauncherVisible: false, + EnableBindingCookie: true, + HTTPOnlyCookieAttribute: true, + } +} + +// BuildRecommendedPolicy assembles the recommended fail-closed Access policy that +// admits exactly the given identity. It returns an error rather than emit a +// policy that would be public — an empty identity, or any result that does not +// pass validateFailClosed, is refused here so a caller can never accidentally +// ship an open door. Scoping by LoginMethods alone yields "everyone who +// authenticates via this SSO IdP" (include everyone + require login_method), +// which is constrained, not public. +func BuildRecommendedPolicy(name string, id AccessIdentity) (AccessPolicy, error) { + if id.empty() { + return AccessPolicy{}, errors.New("cfsetup: recommended policy needs at least one identity (email, email domain, or SSO login method) — refusing to build a public policy") + } + p := AccessPolicy{Name: name, Decision: "allow"} + for _, e := range id.Emails { + p.Include = append(p.Include, accessRule{"email": {"email": e}}) + } + for _, d := range id.EmailDomains { + p.Include = append(p.Include, accessRule{"email_domain": {"domain": d}}) + } + for _, m := range id.LoginMethods { + p.Require = append(p.Require, accessRule{"login_method": {"id": m}}) + } + // If the identity is scoped ONLY by SSO login method, the include set needs a + // base match for the require to narrow; "everyone gated by require login_method" + // is the Cloudflare-recommended authenticated-users shape and stays fail-closed. + if len(p.Include) == 0 { + p.Include = append(p.Include, accessRule{"everyone": {}}) + } + if err := validateFailClosed(p); err != nil { + return AccessPolicy{}, err + } + return p, nil +} + +// validateFailClosed is the load-bearing safety property of this whole package, +// the analog of "never catches the genuine Mojang player" in the reclaim flow: a +// recommended Access policy that guards op.console MUST NOT be public. +// +// It is an ALLOWLIST, not a denylist — the only fail-closed design for a guard +// whose whole job is to catch shapes the current builder does not produce. The +// critical fact is that Cloudflare Access `include` rules combine as OR: a user is +// admitted if they match ANY one include rule. So an `everyone` include makes the +// whole include set public no matter what other identity rules sit beside it (the +// identity rule is pure redundancy in an OR), and only a `require` clause — which +// is AND — can narrow an `everyone` base. A denylist of known-bad shapes would +// miss both everyone-OR-identity and unrecognized public includes (e.g. an +// ip:0.0.0.0/0 rule); the allowlist refuses anything it cannot positively +// recognize as scoped. +// +// A policy passes iff: decision is "allow" (not "bypass", which skips auth, nor +// anything else); the include set is non-empty; and EITHER every include rule is a +// recognized scoped identity (email, email_domain) with no `everyone`, OR the +// include set's `everyone` base is narrowed by a constraining require clause +// (login_method / email_domain / email). Setup runs this before the policy is ever +// POSTed, so even a future builder bug cannot open the door. +func validateFailClosed(p AccessPolicy) error { + switch p.Decision { + case "bypass": + return errors.New("cfsetup: refusing policy with decision \"bypass\" — it skips authentication for everyone (fail-open)") + case "allow": + // the only decision the recommended path emits + default: + return fmt.Errorf("cfsetup: refusing recommended policy with decision %q — must be \"allow\"", p.Decision) + } + if len(p.Include) == 0 { + return errors.New("cfsetup: refusing policy with no include rules") + } + // Classify the include set against the allowlist. Each rule must positively + // resolve to a recognized scoped identity or the `everyone` base; anything + // else — an unrecognized type OR a degenerate empty/malformed rule with no + // recognized key — is treated as potentially-public and refused. + includeHasEveryone := false + for _, r := range p.Include { + scoped := false + everyone := false + for key := range r { + switch key { + case "email", "email_domain": + scoped = true // a scoped identity — safe to OR into the include set + case "everyone": + everyone = true + default: + return fmt.Errorf("cfsetup: refusing policy with unrecognized include rule %q — a fail-closed policy admits only scoped identities (email, email_domain) or an \"everyone\" base narrowed by a require", key) + } + } + if everyone { + includeHasEveryone = true + } + if !scoped && !everyone { + return errors.New("cfsetup: refusing an include rule that is neither a scoped identity nor \"everyone\" (empty or malformed) — fail-closed") + } + } + if !includeHasEveryone { + // Every include rule is a recognized scoped identity; the OR of scoped + // identities is itself scoped. Fail-closed. + return nil + } + // The include set admits everyone; only a constraining require (AND) can save + // it. A require of `everyone` (or any unrecognized type) does not narrow. + requireConstrains := false + for _, r := range p.Require { + for key := range r { + switch key { + case "login_method", "email_domain", "email": + requireConstrains = true + } + } + } + if !requireConstrains { + return errors.New("cfsetup: refusing policy that admits \"everyone\" with no constraining require — that is public access (fail-open)") + } + return nil +} + +// tunnelConfig is the cloudflared config.yml shape: the tunnel UUID, its +// credentials file, and the ordered ingress rules (the last of which MUST be the +// hostname-less catch-all). +type tunnelConfig struct { + Tunnel string `json:"tunnel"` + CredentialsFile string `json:"credentials-file"` + Ingress []ingressRule `json:"ingress"` +} + +// ingressRule is one cloudflared ingress entry. A rule with an empty Hostname is +// the catch-all (must be last). +type ingressRule struct { + Hostname string `json:"hostname,omitempty"` + Service string `json:"service"` +} + +// BuildTunnelConfig renders the cloudflared config.yml that routes each web +// hostname to the local panel origin and terminates in the required fail-shut +// catch-all 404. The game host (the bare root_domain / mc. host) is deliberately +// NOT a hostname here — Minecraft stays raw protocol off the tunnel; only the +// passed web hostnames (console., op.console.) are proxied. +func BuildTunnelConfig(tunnelID, credentialsFile, panelOrigin string, hostnames []string) ([]byte, error) { + if tunnelID == "" { + return nil, errors.New("cfsetup: tunnel id is required") + } + if panelOrigin == "" { + panelOrigin = defaultPanelOrigin + } + if len(hostnames) == 0 { + return nil, errors.New("cfsetup: at least one web hostname is required") + } + cfg := tunnelConfig{Tunnel: tunnelID, CredentialsFile: credentialsFile} + for _, h := range hostnames { + if h == "" { + return nil, errors.New("cfsetup: empty hostname in ingress") + } + cfg.Ingress = append(cfg.Ingress, ingressRule{Hostname: h, Service: panelOrigin}) + } + // The mandatory trailing catch-all: anything not explicitly routed gets a bare + // 404, never a forward to the origin. + cfg.Ingress = append(cfg.Ingress, ingressRule{Service: catchAllService}) + return yaml.Marshal(cfg) +} + +// Preconditions are the gating facts only the operator can satisfy, detected off +// the box (see DetectPreconditions in runner.go) and passed in as data so Setup +// stays unit-testable. +type Preconditions struct { + // CloudflaredPath is the resolved cloudflared binary path; empty = not found. + CloudflaredPath string + // CertExists reports whether ~/.cloudflared/cert.pem is present, i.e. the + // operator has completed `cloudflared tunnel login`. + CertExists bool + // APIToken is the Cloudflare API token for the Access app/policy calls. + APIToken string +} + +func (p Preconditions) check() error { + if p.CloudflaredPath == "" { + return ErrCloudflaredMissing + } + if !p.CertExists { + return ErrNotLoggedIn + } + if strings.TrimSpace(p.APIToken) == "" { + return ErrNoAPIToken + } + return nil +} + +// Runner is the integration seam: every side-effecting step of the setup. The +// real implementation (ExecRunner in runner.go) shells out to cloudflared and +// calls the Cloudflare API and is INTEGRATION-ONLY; tests pass a fake. +type Runner interface { + // CreateTunnel creates (or, idempotently, returns the existing) named tunnel, + // yielding its UUID and the path to its credentials file. + CreateTunnel(ctx context.Context, name string) (id, credentialsFile string, err error) + // RouteDNS points hostname at the tunnel (a proxied CNAME). + RouteDNS(ctx context.Context, tunnelID, hostname string) error + // WriteTunnelConfig persists the rendered config.yml. + WriteTunnelConfig(path string, contents []byte) error + // CreateAccessApplication creates the self-hosted Access app and returns its + // id and the issued JWT `aud` (which felis [auth] access_jwt_aud must adopt). + CreateAccessApplication(ctx context.Context, app AccessApplication) (appID, aud string, err error) + // CreateAccessPolicy attaches policy to the Access app. + CreateAccessPolicy(ctx context.Context, appID string, policy AccessPolicy) error +} + +// Params is the full input to Setup. Hostnames are passed in (composed by the +// caller from the configured root_domain) so this package never hardcodes a +// domain or subdomain scheme. +type Params struct { + PanelHostname string // console. (Player web) + AdminHostname string // op.console. (Operator+SysAdmin web) + PanelOrigin string // where the tunnel forwards; default http://localhost:8080 + TunnelName string + ConfigPath string // where to write config.yml + SessionDuration string + AllowedIdPs []string // restrict the Access app to these IdPs (SSO) + AccessIdentity AccessIdentity // WHO the policy admits (fail-closed) + Pre Preconditions +} + +// Result reports what Setup produced, including the Access `aud` the caller must +// write into felis [auth] access_jwt_aud to make felis-api accept the new edge. +type Result struct { + TunnelID string + CredentialsFile string + ConfigPath string + AccessAppID string + AccessAud string + RoutedHostnames []string +} + +// Setup runs the recommended Cloudflare Tunnel + Access provisioning end to end +// behind the Runner. It is ordered so that EVERY check the box can make happens +// BEFORE any side effect: it gates on preconditions and builds + fail-closed- +// guards the policy first, returning early with no Runner calls if either fails. +// Only then does it create the tunnel, route the web hostnames (never the game +// host), write the config, create the Access app for the admin face, and attach +// the guarded policy. +func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { + if runner == nil { + return nil, errors.New("cfsetup: runner is required") + } + if p.AdminHostname == "" { + return nil, errors.New("cfsetup: admin hostname is required") + } + if p.TunnelName == "" { + return nil, errors.New("cfsetup: tunnel name is required") + } + // 1. Gate on operator-only preconditions — no side effects on failure. + if err := p.Pre.check(); err != nil { + return nil, err + } + // 2. Build and fail-closed-guard the policy BEFORE touching Cloudflare, so a + // public/unscoped policy aborts the whole run with nothing created. + policy, err := BuildRecommendedPolicy("felis-recommended", p.AccessIdentity) + if err != nil { + return nil, err + } + if err := validateFailClosed(policy); err != nil { + return nil, err // belt-and-suspenders: never POST an open policy + } + + hostnames := webHostnames(p) + origin := p.PanelOrigin + if origin == "" { + origin = defaultPanelOrigin + } + + // 3. Create the tunnel. + id, cred, err := runner.CreateTunnel(ctx, p.TunnelName) + if err != nil { + return nil, fmt.Errorf("cfsetup: create tunnel: %w", err) + } + // 4. Route DNS for each WEB hostname only (the game host stays off the tunnel). + for _, h := range hostnames { + if err := runner.RouteDNS(ctx, id, h); err != nil { + return nil, fmt.Errorf("cfsetup: route dns %s: %w", h, err) + } + } + // 5. Render and persist the ingress config. + cfgBytes, err := BuildTunnelConfig(id, cred, origin, hostnames) + if err != nil { + return nil, err + } + if p.ConfigPath != "" { + if err := runner.WriteTunnelConfig(p.ConfigPath, cfgBytes); err != nil { + return nil, fmt.Errorf("cfsetup: write config: %w", err) + } + } + // 6. Front the admin face with a self-hosted Access app. + app := BuildAccessApplication(p.AdminHostname, "Felis SysAdmin Console", p.SessionDuration, p.AllowedIdPs) + appID, aud, err := runner.CreateAccessApplication(ctx, app) + if err != nil { + return nil, fmt.Errorf("cfsetup: create access application: %w", err) + } + // 7. Attach the guarded fail-closed policy. + if err := runner.CreateAccessPolicy(ctx, appID, policy); err != nil { + return nil, fmt.Errorf("cfsetup: create access policy: %w", err) + } + + return &Result{ + TunnelID: id, + CredentialsFile: cred, + ConfigPath: p.ConfigPath, + AccessAppID: appID, + AccessAud: aud, + RoutedHostnames: hostnames, + }, nil +} + +// webHostnames returns the web hostnames to route, in order: panel (console.) +// first when present, then the admin (op.console.) face. The admin host is +// required; the panel host is optional (a SysAdmin may tunnel only the admin +// surface). +func webHostnames(p Params) []string { + var hs []string + if p.PanelHostname != "" { + hs = append(hs, p.PanelHostname) + } + hs = append(hs, p.AdminHostname) + return hs +} diff --git a/internal/cfsetup/cfsetup_test.go b/internal/cfsetup/cfsetup_test.go new file mode 100644 index 0000000..a27ec5d --- /dev/null +++ b/internal/cfsetup/cfsetup_test.go @@ -0,0 +1,337 @@ +package cfsetup + +import ( + "context" + "errors" + "testing" + + "sigs.k8s.io/yaml" +) + +// The Cloudflare Tunnel + Access setup is INTEGRATION-ONLY end to end: the tunnel, +// DNS, and Access resources only exist against the operator's live account. These +// tests pin the two things this box CAN verify and that actually protect the +// operator: +// +// 1. the recommended Access policy is FAIL-CLOSED — it can never serialize to +// public/allow-everyone or skip authentication (the load-bearing property, +// the analog of "reclaim never catches the genuine Mojang player"); and +// 2. the gating refuses with NO side effects when the operator's own +// preconditions (cloudflared, login, API token) are not met. +// +// We deliberately do NOT assert "Setup calls the runner in this exact order" — +// that only restates the code. testRoot is a placeholder; real domains are +// config-only and red-line-forbidden in source. + +const testRoot = "mc.example.net" + +// recordingRunner is a fake Runner that records every side-effecting call so a +// test can assert that a refusal happened BEFORE any Cloudflare mutation. +type recordingRunner struct { + calls []string + tunnelID string + credentials string + appID string + aud string +} + +func (r *recordingRunner) CreateTunnel(_ context.Context, name string) (string, string, error) { + r.calls = append(r.calls, "CreateTunnel:"+name) + id := r.tunnelID + if id == "" { + id = "11111111-2222-3333-4444-555555555555" + } + cred := r.credentials + if cred == "" { + cred = "/root/.cloudflared/" + id + ".json" + } + return id, cred, nil +} + +func (r *recordingRunner) RouteDNS(_ context.Context, tunnelID, hostname string) error { + r.calls = append(r.calls, "RouteDNS:"+hostname) + return nil +} + +func (r *recordingRunner) WriteTunnelConfig(path string, _ []byte) error { + r.calls = append(r.calls, "WriteTunnelConfig:"+path) + return nil +} + +func (r *recordingRunner) CreateAccessApplication(_ context.Context, app AccessApplication) (string, string, error) { + r.calls = append(r.calls, "CreateAccessApplication:"+app.Domain) + appID := r.appID + if appID == "" { + appID = "app-123" + } + aud := r.aud + if aud == "" { + aud = "aud-abc" + } + return appID, aud, nil +} + +func (r *recordingRunner) CreateAccessPolicy(_ context.Context, appID string, policy AccessPolicy) error { + r.calls = append(r.calls, "CreateAccessPolicy:"+policy.Name) + return nil +} + +func goodPreconditions() Preconditions { + return Preconditions{CloudflaredPath: "/usr/local/bin/cloudflared", CertExists: true, APIToken: "tok"} +} + +// TestRecommendedPolicyIsFailClosed is the load-bearing safety property: the +// recommended policy admits a scoped identity and is NEVER public. It checks every +// way the policy could go wrong — an unscoped build, a bare-everyone allow, and a +// bypass decision must all be refused — and that legitimate scopings (a specific +// email, an org SSO domain, and an SSO-IdP-only login method) are accepted. +func TestRecommendedPolicyIsFailClosed(t *testing.T) { + t.Run("scoped by email is allowed and fail-closed", func(t *testing.T) { + p, err := BuildRecommendedPolicy("rec", AccessIdentity{Emails: []string{"owner@example.net"}}) + if err != nil { + t.Fatalf("scoped policy rejected: %v", err) + } + if p.Decision != "allow" { + t.Fatalf("decision = %q, want allow", p.Decision) + } + if err := validateFailClosed(p); err != nil { + t.Fatalf("scoped policy failed the guard: %v", err) + } + }) + + t.Run("scoped by org SSO domain is allowed", func(t *testing.T) { + if _, err := BuildRecommendedPolicy("rec", AccessIdentity{EmailDomains: []string{"example.net"}}); err != nil { + t.Fatalf("email_domain scoping rejected: %v", err) + } + }) + + t.Run("scoped by SSO login method only is allowed (Google/Keycloak/Entra)", func(t *testing.T) { + // IdP-only scoping yields include everyone + require login_method — the + // Cloudflare-recommended authenticated-users shape, which is constrained. + p, err := BuildRecommendedPolicy("rec", AccessIdentity{LoginMethods: []string{"idp-google-123"}}) + if err != nil { + t.Fatalf("SSO login_method scoping rejected: %v", err) + } + if len(p.Require) == 0 { + t.Fatal("SSO-only policy must carry a require login_method constraint") + } + if err := validateFailClosed(p); err != nil { + t.Fatalf("SSO-only policy failed the guard: %v", err) + } + }) + + t.Run("empty identity is refused (no public door)", func(t *testing.T) { + if _, err := BuildRecommendedPolicy("rec", AccessIdentity{}); err == nil { + t.Fatal("an unscoped identity must not build a policy") + } + }) + + t.Run("bare everyone allow is refused", func(t *testing.T) { + public := AccessPolicy{ + Name: "public", + Decision: "allow", + Include: []accessRule{{"everyone": {}}}, + } + if err := validateFailClosed(public); err == nil { + t.Fatal("guard accepted a bare-everyone allow policy — that is public access") + } + }) + + t.Run("everyone OR identity is refused (include rules are OR — everyone wins)", func(t *testing.T) { + // Cloudflare Access include rules combine as OR: a user matches if they + // satisfy ANY rule. So {everyone} OR {email:X} admits everyone — the email + // rule is pure redundancy. Only a require (AND) can narrow an everyone base. + orPublic := AccessPolicy{ + Name: "everyone-or-email", + Decision: "allow", + Include: []accessRule{ + {"everyone": {}}, + {"email": {"email": "owner@example.net"}}, + }, + } + if err := validateFailClosed(orPublic); err == nil { + t.Fatal("guard accepted everyone-OR-identity — that is public access (everyone wins the OR)") + } + }) + + t.Run("unrecognized include type is refused (allowlist, not denylist)", func(t *testing.T) { + // A fail-closed guard must reject include shapes it does not recognize as + // scoped — an ip:0.0.0.0/0 include, for instance, is public but is not one + // of the known-bad shapes a denylist would catch. + ipAny := AccessPolicy{ + Name: "ip-any", + Decision: "allow", + Include: []accessRule{{"ip": {"ip": "0.0.0.0/0"}}}, + } + if err := validateFailClosed(ipAny); err == nil { + t.Fatal("guard accepted an unrecognized include type — a fail-closed guard must allowlist scoped identities only") + } + }) + + t.Run("empty/malformed include rule is refused", func(t *testing.T) { + // A degenerate include rule with no recognized key must not slip through the + // allowlist as if it were scoped. + empty := AccessPolicy{ + Name: "empty-rule", + Decision: "allow", + Include: []accessRule{{}}, + } + if err := validateFailClosed(empty); err == nil { + t.Fatal("guard accepted an empty include rule — a fail-closed guard must refuse rules it cannot recognize as scoped") + } + }) + + t.Run("everyone narrowed by a non-constraining require is refused", func(t *testing.T) { + // A require of {everyone} does not narrow anything; everyone base + such a + // require is still public. + noopRequire := AccessPolicy{ + Name: "everyone-require-everyone", + Decision: "allow", + Include: []accessRule{{"everyone": {}}}, + Require: []accessRule{{"everyone": {}}}, + } + if err := validateFailClosed(noopRequire); err == nil { + t.Fatal("guard accepted everyone-include narrowed only by an everyone-require — still public") + } + }) + + t.Run("bypass decision is refused", func(t *testing.T) { + bypass := AccessPolicy{ + Name: "bypass", + Decision: "bypass", + Include: []accessRule{{"email": {"email": "owner@example.net"}}}, + } + if err := validateFailClosed(bypass); err == nil { + t.Fatal("guard accepted a bypass policy — bypass skips authentication for everyone") + } + }) +} + +// TestSetupGatingHasNoSideEffects proves each operator-only precondition is a hard +// gate: a missing cloudflared, an absent login (no cert.pem), or a missing API +// token each aborts Setup with the right error and — critically — with ZERO calls +// to the runner, so a gated refusal never half-creates a tunnel or DNS record. +func TestSetupGatingHasNoSideEffects(t *testing.T) { + base := Params{ + PanelHostname: "console." + testRoot, + AdminHostname: "op.console." + testRoot, + TunnelName: "felis", + ConfigPath: "/etc/felis/cloudflared.yml", + AccessIdentity: AccessIdentity{Emails: []string{"owner@example.net"}}, + } + + cases := []struct { + name string + pre Preconditions + want error + }{ + {"no cloudflared", Preconditions{CertExists: true, APIToken: "tok"}, ErrCloudflaredMissing}, + {"not logged in", Preconditions{CloudflaredPath: "/bin/cloudflared", APIToken: "tok"}, ErrNotLoggedIn}, + {"no api token", Preconditions{CloudflaredPath: "/bin/cloudflared", CertExists: true}, ErrNoAPIToken}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runner := &recordingRunner{} + p := base + p.Pre = tc.pre + _, err := Setup(context.Background(), runner, p) + if !errors.Is(err, tc.want) { + t.Fatalf("err = %v, want %v", err, tc.want) + } + if len(runner.calls) != 0 { + t.Fatalf("a gated refusal made side effects: %v", runner.calls) + } + }) + } +} + +// TestSetupRefusesUnscopedPolicyBeforeSideEffects is defense-in-depth: even with +// every precondition met, an empty AccessIdentity (which would yield a public +// policy) aborts Setup BEFORE any tunnel/DNS/app is created. The fail-closed guard +// is wired into the orchestrator, not merely a standalone helper. +func TestSetupRefusesUnscopedPolicyBeforeSideEffects(t *testing.T) { + runner := &recordingRunner{} + p := Params{ + PanelHostname: "console." + testRoot, + AdminHostname: "op.console." + testRoot, + TunnelName: "felis", + AccessIdentity: AccessIdentity{}, // unscoped → public → must be refused + Pre: goodPreconditions(), + } + if _, err := Setup(context.Background(), runner, p); err == nil { + t.Fatal("Setup accepted an unscoped (public) identity") + } + if len(runner.calls) != 0 { + t.Fatalf("Setup made side effects before refusing the public policy: %v", runner.calls) + } +} + +// TestSetupSucceedsAndReportsAud is the happy path against the fake: with good +// preconditions and a scoped identity, Setup routes exactly the web hostnames +// (never the game host), and reports the Access aud the caller must adopt into +// felis [auth] access_jwt_aud. +func TestSetupSucceedsAndReportsAud(t *testing.T) { + runner := &recordingRunner{aud: "felis-aud-xyz"} + p := Params{ + PanelHostname: "console." + testRoot, + AdminHostname: "op.console." + testRoot, + TunnelName: "felis", + ConfigPath: "/etc/felis/cloudflared.yml", + AccessIdentity: AccessIdentity{Emails: []string{"owner@example.net"}}, + Pre: goodPreconditions(), + } + res, err := Setup(context.Background(), runner, p) + if err != nil { + t.Fatalf("Setup: %v", err) + } + if res.AccessAud != "felis-aud-xyz" { + t.Fatalf("AccessAud = %q, want felis-aud-xyz (felis-api must validate this)", res.AccessAud) + } + // Exactly the two web hostnames are routed; the bare game host never is. + if len(res.RoutedHostnames) != 2 { + t.Fatalf("routed %v, want the two web hostnames only", res.RoutedHostnames) + } + for _, h := range res.RoutedHostnames { + if h == testRoot { + t.Fatalf("the game host %q must never be routed through the tunnel", testRoot) + } + } +} + +// TestIngressSafetyInvariants parses the generated cloudflared config back and +// asserts the properties that protect the origin: the final rule is the +// hostname-less fail-shut 404 catch-all, every routed hostname points at the panel +// origin and nowhere else, and the raw game host is never an ingress hostname. +func TestIngressSafetyInvariants(t *testing.T) { + const origin = "http://localhost:8080" + hostnames := []string{"console." + testRoot, "op.console." + testRoot} + raw, err := BuildTunnelConfig("11111111-2222-3333-4444-555555555555", "/root/.cloudflared/x.json", origin, hostnames) + if err != nil { + t.Fatalf("BuildTunnelConfig: %v", err) + } + var cfg tunnelConfig + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("generated config is not valid YAML: %v\n%s", err, raw) + } + if len(cfg.Ingress) == 0 { + t.Fatal("no ingress rules") + } + // The catch-all MUST be last and MUST be the fail-shut 404. + last := cfg.Ingress[len(cfg.Ingress)-1] + if last.Hostname != "" || last.Service != catchAllService { + t.Fatalf("final ingress rule = %+v, want hostname-less %s catch-all", last, catchAllService) + } + // Every non-terminal rule routes a known web hostname to the panel origin, and + // the raw game host is never routed. + for _, rule := range cfg.Ingress[:len(cfg.Ingress)-1] { + if rule.Hostname == testRoot { + t.Fatalf("the raw game host %q must never be an ingress hostname", testRoot) + } + if rule.Hostname == "" { + t.Fatal("a non-terminal rule has no hostname (only the catch-all may)") + } + if rule.Service != origin { + t.Fatalf("hostname %q routes to %q, want the panel origin %q", rule.Hostname, rule.Service, origin) + } + } +} diff --git a/internal/cfsetup/runner.go b/internal/cfsetup/runner.go new file mode 100644 index 0000000..6f2ea44 --- /dev/null +++ b/internal/cfsetup/runner.go @@ -0,0 +1,222 @@ +package cfsetup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// This file is INTEGRATION-ONLY. ExecRunner shells out to the real `cloudflared` +// binary and calls the live Cloudflare API; none of it can run — or be honestly +// faked — on a box without the operator's own Cloudflare account and the +// interactive `cloudflared tunnel login` consent already completed. The orchestration +// that uses it (Setup) and the request-body/guard logic are unit-verified in +// cfsetup.go; what lives here is exercised only against a real account. + +const defaultAPIBase = "https://api.cloudflare.com/client/v4" + +// DetectPreconditions inspects the local environment for the gating facts Setup +// needs: whether cloudflared is installed and whether the operator has logged in +// (cert.pem present). The API token is supplied by the caller (the TUI prompts +// for it); it is passed through so the returned value is ready to hand to Setup. +// This only READS the environment — it performs no Cloudflare side effects — but +// it touches the real filesystem/PATH, so it is integration-side. +func DetectPreconditions(apiToken string) Preconditions { + pre := Preconditions{APIToken: apiToken} + if path, err := exec.LookPath("cloudflared"); err == nil { + pre.CloudflaredPath = path + } + if home, err := os.UserHomeDir(); err == nil { + if _, err := os.Stat(filepath.Join(home, ".cloudflared", "cert.pem")); err == nil { + pre.CertExists = true + } + } + return pre +} + +// ExecRunner is the production Runner: cloudflared via os/exec for the tunnel and +// the Cloudflare API via HTTP for Access. +type ExecRunner struct { + // Cloudflared is the resolved cloudflared binary path (Preconditions.CloudflaredPath). + Cloudflared string + // APIToken authenticates the Access API calls (Bearer). + APIToken string + // AccountID is the Cloudflare account the Access app/policy are created under. + AccountID string + // APIBase defaults to the public Cloudflare API; overridable for testing. + APIBase string + // HTTP is the client used for API calls; nil means a default with a timeout. + HTTP *http.Client +} + +func (r *ExecRunner) httpClient() *http.Client { + if r.HTTP != nil { + return r.HTTP + } + return &http.Client{Timeout: 30 * time.Second} +} + +func (r *ExecRunner) apiBase() string { + if r.APIBase != "" { + return r.APIBase + } + return defaultAPIBase +} + +// tunnelIDRE extracts the UUID cloudflared prints when a tunnel is created or +// already exists. +var tunnelIDRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) + +// CreateTunnel runs `cloudflared tunnel create `. cloudflared writes the +// credentials JSON under ~/.cloudflared/.json and prints the id; we parse it +// out. If the tunnel already exists this returns its id (idempotent re-run). +func (r *ExecRunner) CreateTunnel(ctx context.Context, name string) (string, string, error) { + out, err := r.runCloudflared(ctx, "tunnel", "create", name) + if err != nil { + // An "already exists" is not fatal — recover the id via `tunnel list`. + if id, lerr := r.lookupTunnel(ctx, name); lerr == nil && id != "" { + return id, r.credentialsPath(id), nil + } + return "", "", err + } + id := tunnelIDRE.FindString(out) + if id == "" { + return "", "", fmt.Errorf("cfsetup: could not parse tunnel id from cloudflared output: %s", out) + } + return id, r.credentialsPath(id), nil +} + +// lookupTunnel finds an existing tunnel's id by name via `tunnel list`. +func (r *ExecRunner) lookupTunnel(ctx context.Context, name string) (string, error) { + out, err := r.runCloudflared(ctx, "tunnel", "list", "--name", name, "--output", "json") + if err != nil { + return "", err + } + var tunnels []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(out), &tunnels); err != nil { + return "", err + } + for _, t := range tunnels { + if t.Name == name { + return t.ID, nil + } + } + return "", fmt.Errorf("cfsetup: tunnel %q not found", name) +} + +func (r *ExecRunner) credentialsPath(id string) string { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".cloudflared", id+".json") + } + return id + ".json" +} + +// RouteDNS runs `cloudflared tunnel route dns `, creating the +// proxied CNAME. It is idempotent on cloudflared's side for an existing record. +func (r *ExecRunner) RouteDNS(ctx context.Context, tunnelID, hostname string) error { + _, err := r.runCloudflared(ctx, "tunnel", "route", "dns", tunnelID, hostname) + if err != nil && strings.Contains(err.Error(), "already exists") { + return nil + } + return err +} + +// WriteTunnelConfig writes the rendered config.yml, creating its parent directory. +func (r *ExecRunner) WriteTunnelConfig(path string, contents []byte) error { + if dir := filepath.Dir(path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + return os.WriteFile(path, contents, 0o644) +} + +// CreateAccessApplication POSTs the self-hosted Access app and returns its id and +// issued aud (spec §14: the aud felis [auth] access_jwt_aud must adopt). +func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessApplication) (string, string, error) { + var resp struct { + Result struct { + ID string `json:"id"` + AUD string `json:"aud"` + } `json:"result"` + } + if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps", r.AccountID), app, &resp); err != nil { + return "", "", err + } + return resp.Result.ID, resp.Result.AUD, nil +} + +// CreateAccessPolicy POSTs the policy onto the Access app. +func (r *ExecRunner) CreateAccessPolicy(ctx context.Context, appID string, policy AccessPolicy) error { + return r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps/%s/policies", r.AccountID, appID), policy, nil) +} + +// runCloudflared executes the cloudflared binary with the given args, returning +// combined output. The interactive `tunnel login` browser consent is NOT done +// here — it is a separate, operator-driven step the TUI suspends to run. +func (r *ExecRunner) runCloudflared(ctx context.Context, args ...string) (string, error) { + bin := r.Cloudflared + if bin == "" { + bin = "cloudflared" + } + cmd := exec.CommandContext(ctx, bin, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Run(); err != nil { + return buf.String(), fmt.Errorf("cfsetup: cloudflared %s: %w: %s", strings.Join(args, " "), err, buf.String()) + } + return buf.String(), nil +} + +// apiPost sends an authenticated JSON POST to the Cloudflare API and, on a +// non-2xx or success:false body, returns the error. out, when non-nil, receives +// the decoded response. +func (r *ExecRunner) apiPost(ctx context.Context, path string, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.apiBase()+path, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+r.APIToken) + req.Header.Set("Content-Type", "application/json") + resp, err := r.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("cfsetup: Cloudflare API %s: status %d: %s", path, resp.StatusCode, string(raw)) + } + // Cloudflare wraps every response in {success, errors, result}; surface a + // success:false even on a 200. + var envelope struct { + Success bool `json:"success"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.Unmarshal(raw, &envelope); err == nil && !envelope.Success && len(envelope.Errors) > 0 { + return fmt.Errorf("cfsetup: Cloudflare API %s: %s", path, string(raw)) + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("cfsetup: decode Cloudflare API %s response: %w", path, err) + } + } + return nil +}