Unverified Commit 5450c268 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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
parent 9c466329
Loading
Loading
Loading
Loading
+14 −1
Changes for cmd/felis/breakglass.go: 14 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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",
+18 −5
Changes for internal/cfsetup/cfsetup.go: 18 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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)
	}
+33 −0
Changes for internal/cfsetup/cfsetup_test.go: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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{"[email protected]"}},
		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{