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:
3 files changed
+1007
No files matched your search
@@ -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.<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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <name>`. cloudflared writes the
|
||||||
|
// credentials JSON under ~/.cloudflared/<id>.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 <tunnelID> <hostname>`, 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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user