From 5450c268f442a8ed99df2dc6b99487d789345dbc Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 28 Jun 2026 16:43:10 +0900 Subject: [PATCH] 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 --- cmd/felis/breakglass.go | 15 ++++++++++++++- internal/cfsetup/cfsetup.go | 23 +++++++++++++++++----- internal/cfsetup/cfsetup_test.go | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index a9419cf..c3cd887 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -454,6 +454,12 @@ const ( const ( defaultTunnelName = "felis" defaultTunnelConfigPath = "/etc/felis/cloudflared.yml" + + // Cloudflare Dashboard prefill URL for the API token this flow actually uses: + // Access app/policy management. Tunnel creation and DNS routing are authorized by + // the operator's cloudflared browser login, not by this token. + cloudflareAccessTokenTemplateURL = "https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22access%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=*&zoneId=all" + cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) // authResultMsg carries the outcome of the off-goroutine admin credential check. @@ -1178,6 +1184,12 @@ func (m *bgModel) View() string { b.WriteString("console with a fail-closed Access policy, using YOUR own Cloudflare account.\n") b.WriteString(bgHintStyle.Render("Hostnames are editable on the next screen; the Minecraft game host is not tunneled.") + "\n\n") + b.WriteString(bgLabelStyle.Render("Cloudflare authorization:") + "\n") + b.WriteString(" 1. Press " + bgLabelStyle.Render("l") + " for `cloudflared tunnel login` browser consent.\n") + b.WriteString(" 2. Create the Access API token from:\n") + b.WriteString(" " + cloudflareAccessTokenTemplateURL + "\n") + b.WriteString(bgHintStyle.Render("The link pre-fills the Dashboard token form; Cloudflare still asks you to review and create it.") + "\n") + b.WriteString(bgHintStyle.Render("Docs: "+cloudflareAPITokenDocsURL) + "\n\n") b.WriteString(bgLabelStyle.Render("Default web hostnames:") + "\n") if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" { b.WriteString(" • " + panel + bgHintStyle.Render(" (Player console)") + "\n") @@ -1213,7 +1225,8 @@ func (m *bgModel) View() string { case stepEdgeInput: b.WriteString(bgLabelStyle.Render("Cloudflare edge · credentials & scope") + "\n") - b.WriteString(bgHintStyle.Render("Token scopes: Account › Cloudflare Tunnel:Edit · Zone › DNS:Edit · Account › Access Apps and Policies:Edit") + "\n\n") + b.WriteString(bgHintStyle.Render("API token: Account > Access Apps and Policies:Edit. Tunnel/DNS uses `cloudflared tunnel login`.") + "\n") + b.WriteString(bgHintStyle.Render("Token template: "+cloudflareAccessTokenTemplateURL) + "\n\n") labels := []string{ "Cloudflare API token", "Cloudflare account ID", diff --git a/internal/cfsetup/cfsetup.go b/internal/cfsetup/cfsetup.go index c71eafa..3d746c8 100644 --- a/internal/cfsetup/cfsetup.go +++ b/internal/cfsetup/cfsetup.go @@ -314,6 +314,10 @@ func (p Preconditions) check() error { // 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. @@ -385,6 +389,15 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { 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 @@ -392,18 +405,18 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { origin = defaultPanelOrigin } - // 3. Create the tunnel. + // 4. 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). + // 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) } } - // 5. Render and persist the ingress config. + // 6. Render and persist the ingress config. cfgBytes, err := BuildTunnelConfig(id, cred, origin, hostnames) if err != nil { return nil, err @@ -413,13 +426,13 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { return nil, fmt.Errorf("cfsetup: write config: %w", err) } } - // 6. Front the admin face with a self-hosted Access app. + // 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) } - // 7. Attach the guarded fail-closed policy. + // 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) } diff --git a/internal/cfsetup/cfsetup_test.go b/internal/cfsetup/cfsetup_test.go index a27ec5d..25d7cbc 100644 --- a/internal/cfsetup/cfsetup_test.go +++ b/internal/cfsetup/cfsetup_test.go @@ -35,6 +35,17 @@ type recordingRunner struct { aud string } +type verifyingRunner struct { + recordingRunner + verified bool + verifyErr error +} + +func (r *verifyingRunner) VerifyAPIToken(_ context.Context) error { + r.verified = true + return r.verifyErr +} + func (r *recordingRunner) CreateTunnel(_ context.Context, name string) (string, string, error) { r.calls = append(r.calls, "CreateTunnel:"+name) id := r.tunnelID @@ -249,6 +260,28 @@ func TestSetupGatingHasNoSideEffects(t *testing.T) { // 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 TestSetupVerifiesAPITokenBeforeCloudflareMutations(t *testing.T) { + tokenErr := errors.New("token inactive") + runner := &verifyingRunner{verifyErr: tokenErr} + p := Params{ + PanelHostname: "console." + testRoot, + AdminHostname: "op.console." + testRoot, + TunnelName: "felis", + AccessIdentity: AccessIdentity{Emails: []string{"owner@example.net"}}, + Pre: goodPreconditions(), + } + _, err := Setup(context.Background(), runner, p) + if !errors.Is(err, tokenErr) { + t.Fatalf("err = %v, want token verifier error", err) + } + if !runner.verified { + t.Fatal("Setup did not verify the API token") + } + if len(runner.calls) != 0 { + t.Fatalf("token verification failure made Cloudflare mutations: %v", runner.calls) + } +} + func TestSetupRefusesUnscopedPolicyBeforeSideEffects(t *testing.T) { runner := &recordingRunner{} p := Params{