Files
Felis/internal/cfsetup/cfsetup.go
T
flyemoji 5450c268f4 chore: normalize line endings and apply formatting
- Convert CRLF to LF across Go, panel, and plugin files
- Add Cloudflare API token template URL to breakGlass TUI edge intro
- Verify API token in cfsetup before creating tunnel, DNS, or Access app
2026-06-28 16:43:10 +09:00

462 lines
20 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 apiTokenVerifier interface {
VerifyAPIToken(ctx context.Context) error
}
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
}
// 3. When the real runner can verify the token, do that read-only Cloudflare API
// check before creating tunnels or DNS records. It catches expired/invalid
// tokens earlier; Access account/permission failures can still surface on the
// Access app/policy calls below.
if verifier, ok := runner.(apiTokenVerifier); ok {
if err := verifier.VerifyAPIToken(ctx); err != nil {
return nil, fmt.Errorf("cfsetup: verify Cloudflare API token: %w", err)
}
}
hostnames := webHostnames(p)
origin := p.PanelOrigin
if origin == "" {
origin = defaultPanelOrigin
}
// 4. Create the tunnel.
id, cred, err := runner.CreateTunnel(ctx, p.TunnelName)
if err != nil {
return nil, fmt.Errorf("cfsetup: create tunnel: %w", err)
}
// 5. 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)
}
}
// 6. 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)
}
}
// 7. 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)
}
// 8. 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
}