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
This commit is contained in:
3 files changed
+65
-6
No files matched your search
+14
-1
@@ -454,6 +454,12 @@ const (
|
|||||||
const (
|
const (
|
||||||
defaultTunnelName = "felis"
|
defaultTunnelName = "felis"
|
||||||
defaultTunnelConfigPath = "/etc/felis/cloudflared.yml"
|
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.
|
// 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("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(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")
|
b.WriteString(bgLabelStyle.Render("Default web hostnames:") + "\n")
|
||||||
if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" {
|
if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" {
|
||||||
b.WriteString(" • " + panel + bgHintStyle.Render(" (Player console)") + "\n")
|
b.WriteString(" • " + panel + bgHintStyle.Render(" (Player console)") + "\n")
|
||||||
@@ -1213,7 +1225,8 @@ func (m *bgModel) View() string {
|
|||||||
|
|
||||||
case stepEdgeInput:
|
case stepEdgeInput:
|
||||||
b.WriteString(bgLabelStyle.Render("Cloudflare edge · credentials & scope") + "\n")
|
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{
|
labels := []string{
|
||||||
"Cloudflare API token",
|
"Cloudflare API token",
|
||||||
"Cloudflare account ID",
|
"Cloudflare account ID",
|
||||||
|
|||||||
@@ -314,6 +314,10 @@ func (p Preconditions) check() error {
|
|||||||
// Runner is the integration seam: every side-effecting step of the setup. The
|
// Runner is the integration seam: every side-effecting step of the setup. The
|
||||||
// real implementation (ExecRunner in runner.go) shells out to cloudflared and
|
// real implementation (ExecRunner in runner.go) shells out to cloudflared and
|
||||||
// calls the Cloudflare API and is INTEGRATION-ONLY; tests pass a fake.
|
// calls the Cloudflare API and is INTEGRATION-ONLY; tests pass a fake.
|
||||||
|
type apiTokenVerifier interface {
|
||||||
|
VerifyAPIToken(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
// CreateTunnel creates (or, idempotently, returns the existing) named tunnel,
|
// CreateTunnel creates (or, idempotently, returns the existing) named tunnel,
|
||||||
// yielding its UUID and the path to its credentials file.
|
// 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 {
|
if err := validateFailClosed(policy); err != nil {
|
||||||
return nil, err // belt-and-suspenders: never POST an open policy
|
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)
|
hostnames := webHostnames(p)
|
||||||
origin := p.PanelOrigin
|
origin := p.PanelOrigin
|
||||||
@@ -392,18 +405,18 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) {
|
|||||||
origin = defaultPanelOrigin
|
origin = defaultPanelOrigin
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Create the tunnel.
|
// 4. Create the tunnel.
|
||||||
id, cred, err := runner.CreateTunnel(ctx, p.TunnelName)
|
id, cred, err := runner.CreateTunnel(ctx, p.TunnelName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cfsetup: create tunnel: %w", err)
|
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 {
|
for _, h := range hostnames {
|
||||||
if err := runner.RouteDNS(ctx, id, h); err != nil {
|
if err := runner.RouteDNS(ctx, id, h); err != nil {
|
||||||
return nil, fmt.Errorf("cfsetup: route dns %s: %w", h, err)
|
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)
|
cfgBytes, err := BuildTunnelConfig(id, cred, origin, hostnames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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)
|
app := BuildAccessApplication(p.AdminHostname, "Felis SysAdmin Console", p.SessionDuration, p.AllowedIdPs)
|
||||||
appID, aud, err := runner.CreateAccessApplication(ctx, app)
|
appID, aud, err := runner.CreateAccessApplication(ctx, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cfsetup: create access application: %w", err)
|
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 {
|
if err := runner.CreateAccessPolicy(ctx, appID, policy); err != nil {
|
||||||
return nil, fmt.Errorf("cfsetup: create access policy: %w", err)
|
return nil, fmt.Errorf("cfsetup: create access policy: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ type recordingRunner struct {
|
|||||||
aud string
|
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) {
|
func (r *recordingRunner) CreateTunnel(_ context.Context, name string) (string, string, error) {
|
||||||
r.calls = append(r.calls, "CreateTunnel:"+name)
|
r.calls = append(r.calls, "CreateTunnel:"+name)
|
||||||
id := r.tunnelID
|
id := r.tunnelID
|
||||||
@@ -249,6 +260,28 @@ func TestSetupGatingHasNoSideEffects(t *testing.T) {
|
|||||||
// every precondition met, an empty AccessIdentity (which would yield a public
|
// 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
|
// policy) aborts Setup BEFORE any tunnel/DNS/app is created. The fail-closed guard
|
||||||
// is wired into the orchestrator, not merely a standalone helper.
|
// 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) {
|
func TestSetupRefusesUnscopedPolicyBeforeSideEffects(t *testing.T) {
|
||||||
runner := &recordingRunner{}
|
runner := &recordingRunner{}
|
||||||
p := Params{
|
p := Params{
|
||||||
|
|||||||
Reference in new issue
Block a user