feat(cfsetup): recommended Cloudflare Tunnel + Access edge setup

Add internal/cfsetup, the verifiable core of an optional one-click
Cloudflare Tunnel + Access provisioning flow for the SysAdmin edge
(spec §14). It is domain-agnostic (every FQDN is composed from the
configured root_domain) and IdP-agnostic (any valid Access JWT aud is
accepted, whichever IdP fronts it), so a SysAdmin who brings their own
domain or Zero-Trust scheme stays fully supported.

The load-bearing safety property is a fail-closed guard on the
recommended Access policy. validateFailClosed is an allowlist that
refuses any policy that could be public: a bypass/non-allow decision, an
empty include, an "everyone" include not narrowed by a constraining
require (include rules are OR, so "everyone" beside an identity is still
public), or any include rule it cannot positively recognize as a scoped
identity. Setup runs the guard before any side effect, so a public
policy aborts the run with nothing created.

The tunnel ingress routes only the web hostnames to the local panel
origin and terminates in the mandatory fail-shut 404 catch-all; the raw
game host is never proxied. Gating preconditions (cloudflared present,
tunnel login completed, API token) are hard checks with no side effects
on failure.

The actual cloudflared exec, DNS routing, and Access API calls live in
runner.go and are integration-only: they require the operator's own live
Cloudflare account and interactive browser consent, which cannot be
unit-tested. The policy guard, ingress generation, request bodies, and
gating are unit-tested.
This commit is contained in:
flyemoji committed 2026-06-27 14:18:49 +09:00
1 parent a29571de39
commit 53a76640a4
3 files changed
+1007

No files matched your search

+337
View File
@@ -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{"[email protected]"}})
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": "[email protected]"}},
},
}
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": "[email protected]"}}},
}
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{"[email protected]"}},
}
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{"[email protected]"}},
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)
}
}
}