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.
449 lines
19 KiB
Go
449 lines
19 KiB
Go
// 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.<root_domain> (Player web)
|
|
AdminHostname string // op.console.<root_domain> (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
|
|
}
|