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.
338 lines
13 KiB
Go
338 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|