feat(cli): add felis setup first-run console with reclaim protection and cfsetup idempotency
- Add `felis setup` TUI for initial Owner provisioning and optional Cloudflare edge - Refactor breakGlass to share console TUI model (runConsoleTUI) with setup mode - Session auth respects configured [auth].admin_hostname; fallback to op.console.<root> - Protect linked Yggdrasil admins from Mojang-priority reclaim (spec §B3) - cfsetup: idempotent Access app/policy creation, better 401/403 errors, GET + lookup - Bootstrap: auto-install cloudflared, symlink /etc/felis/felis.toml - Add sequence diagrams for ping-to-join, claim, and link flows
This commit is contained in:
15 files changed
+957
-126
No files matched your search
@@ -247,6 +247,22 @@ func (f *fakeRepo) ReclaimUsername(_ context.Context, id, squatterUUID, username
|
||||
func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool, error) {
|
||||
return f.blacklist[mcUUID], nil
|
||||
}
|
||||
|
||||
// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked,
|
||||
// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so
|
||||
// an SSO Operator (role='admin', empty PasswordHash) is protected like any other.
|
||||
func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
|
||||
userID, ok := f.links[mcUUID]
|
||||
if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
|
||||
return false, nil
|
||||
}
|
||||
for _, u := range f.staff {
|
||||
if u.ID == userID && u.Role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
|
||||
ok, present := f.claimOK[n]
|
||||
if !present {
|
||||
@@ -1015,6 +1031,34 @@ func TestErrorEnvelopeHasRequestID(t *testing.T) {
|
||||
|
||||
// ---- real AccessVerifier (JWT aud) ----
|
||||
|
||||
func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||
repo.staff["owner"] = &StaffUser{ID: "u1", Email: "[email protected]", Role: "admin"}
|
||||
token := "session-token"
|
||||
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: time.Now().Add(time.Hour)}
|
||||
auth := SessionAuth{Repo: repo, RootDomain: "old.example.net", AdminHostname: "op.console.mc.example.net"}
|
||||
|
||||
r := httptest.NewRequest("GET", "https://op.console.mc.example.net/api/v1/me", nil)
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
||||
p, err := auth.Authenticate(r)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if !p.ViaAdminAccess {
|
||||
t.Fatalf("configured admin hostname should grant admin-path access, got %+v", p)
|
||||
}
|
||||
|
||||
r = httptest.NewRequest("GET", "https://op.console.old.example.net/api/v1/me", nil)
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token})
|
||||
p, err = auth.Authenticate(r)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate fallback host: %v", err)
|
||||
}
|
||||
if p.ViaAdminAccess {
|
||||
t.Fatalf("root-domain fallback host must not grant admin-path access when admin_hostname is configured")
|
||||
}
|
||||
}
|
||||
func TestAccessVerifier(t *testing.T) {
|
||||
key := []byte("test-signing-key")
|
||||
keyfunc := func(*jwt.Token) (any, error) { return key, nil }
|
||||
|
||||
@@ -75,6 +75,31 @@ func (a *API) handleReclaimUsername(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username is required"))
|
||||
return
|
||||
}
|
||||
// Admin-on-Yggdrasil exception (spec §B3). Before barring the holder, check
|
||||
// whether the displaced UUID is a Linked Operator/SysAdmin authenticating through
|
||||
// the third-party Yggdrasil. Such a holder is staff on the Login Server, not a
|
||||
// Mojang squatter, so Mojang priority must NOT displace them: refuse the reclaim
|
||||
// outright — no bar, no stash — so the protected admin never enters the blacklist
|
||||
// and the login gate naturally passes them. The exception is scoped strictly to
|
||||
// admins; an ordinary thirdparty player is still reclaimed (Mojang priority holds).
|
||||
protected, err := a.Repo.IsProtectedAdminLink(r.Context(), req.SquatterUUID)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if protected {
|
||||
// Distinct audit action so a refusal is never mistaken for a bar — the
|
||||
// accountability record shows the reclaim was declined, and why.
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"username": req.Username, "squatter_uuid": req.SquatterUUID, "reason": "protected_admin"})
|
||||
_ = a.Repo.Audit(r.Context(), AuditEntry{
|
||||
Actor: "velocity", Source: "internal", Action: "player.reclaim.refused",
|
||||
RequestID: requestIDFromContext(r.Context()), Payload: payload,
|
||||
})
|
||||
writeError(w, r, newError(http.StatusConflict, "protected_admin",
|
||||
"that username belongs to a linked administrator on the login server and cannot be reclaimed"))
|
||||
return
|
||||
}
|
||||
id, err := newHoldID()
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
|
||||
@@ -88,6 +88,116 @@ func TestReclaimNeverCatchesGenuineMojangPlayer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReclaimProtectsAdminOnYggdrasil is the admin-on-Yggdrasil exception (spec §B3),
|
||||
// the second safety property alongside the genuine-Mojang case: a Linked
|
||||
// Operator/SysAdmin who authenticates through the third-party Yggdrasil is staff on the
|
||||
// Login Server, not a Mojang squatter, so a Mojang-priority reclaim must REFUSE rather
|
||||
// than bar them. The reclaim is declined (409 protected_admin), nothing is barred or
|
||||
// stashed, the login gate consequently passes the admin's UUID end-to-end, and the
|
||||
// refusal lands in the audit log under a DISTINCT action so it can never be mistaken
|
||||
// for a bar.
|
||||
func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
|
||||
const adminUUID = "0a11dead-0000-0000-0000-00000000ad11"
|
||||
repo := newFakeRepo()
|
||||
// An Operator who linked in-game through the third-party Yggdrasil (auth_source).
|
||||
repo.staff["operator1"] = &StaffUser{ID: "op-1", Username: "operator1", Role: "admin", PasswordHash: "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO"}
|
||||
repo.links[adminUUID] = "op-1"
|
||||
repo.linkAuthSource[adminUUID] = authSourceThirdParty
|
||||
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
ih := api.InternalHandler()
|
||||
|
||||
body := `{"squatter_uuid":"` + adminUUID + `","username":"Operator"}`
|
||||
w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil)
|
||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "protected_admin" {
|
||||
t.Fatalf("reclaim of a protected admin: code = %d body %s, want 409 protected_admin", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Nothing was barred and nothing was stashed — the reclaim was refused outright.
|
||||
if len(repo.blacklist) != 0 || len(repo.holds) != 0 {
|
||||
t.Fatalf("a refused reclaim must not bar or stash anything: blacklist=%v holds=%v", repo.blacklist, repo.holds)
|
||||
}
|
||||
// End-to-end: the login gate consequently passes the admin's UUID.
|
||||
w = do(ih, "GET", "/api/v1/internal/player/blacklist/"+adminUUID, "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("gate check: code = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := acctBody(t, w)["blacklisted"]; got != false {
|
||||
t.Fatalf("protected admin blacklisted = %v, want false", got)
|
||||
}
|
||||
// The refusal is audited under a distinct action, separable from a real bar.
|
||||
if len(repo.audits) != 1 {
|
||||
t.Fatalf("audits = %d, want 1 refusal row", len(repo.audits))
|
||||
}
|
||||
a := repo.audits[0]
|
||||
if a.Action != "player.reclaim.refused" || a.Actor != "velocity" || a.Source != "internal" {
|
||||
t.Fatalf("audit = %+v, want player.reclaim.refused/velocity/internal", a)
|
||||
}
|
||||
var p map[string]string
|
||||
if err := json.Unmarshal(a.Payload, &p); err != nil {
|
||||
t.Fatalf("audit payload not JSON: %v (%s)", err, a.Payload)
|
||||
}
|
||||
if p["reason"] != "protected_admin" || p["squatter_uuid"] != adminUUID {
|
||||
t.Errorf("audit payload = %v, want reason:protected_admin squatter_uuid:%s", p, adminUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReclaimAdminProtectionScope pins the exact predicate the exception turns on so a
|
||||
// future broadening or narrowing of it cannot pass silently. Protection holds for, and
|
||||
// ONLY for, a linked holder that is BOTH authenticated via the third-party Yggdrasil
|
||||
// AND an admin:
|
||||
// - a thirdparty NON-admin player is still reclaimed (pins role='admin') — Mojang
|
||||
// priority must keep displacing ordinary squatters;
|
||||
// - a Mojang-authenticated admin is still reclaimed (pins auth_source='thirdparty') —
|
||||
// an admin's Mojang identity has no Login-Server name to protect (and Mojang names
|
||||
// are unique, so this is operationally moot, but it locks the conjunct);
|
||||
// - an SSO Operator with NO local password is still protected (pins the deliberate
|
||||
// ABSENCE of a password_hash test) — signing in via Cloudflare Access (§14) leaves
|
||||
// role='admin' with a NULL hash, and that holder must be protected all the same.
|
||||
func TestReclaimAdminProtectionScope(t *testing.T) {
|
||||
const squatter = "0a11dead-0000-0000-0000-00000000ad11"
|
||||
cases := []struct {
|
||||
name string
|
||||
role string
|
||||
auth string
|
||||
passHash string
|
||||
protected bool // true: reclaim refused (409); false: reclaim succeeds (200, barred)
|
||||
}{
|
||||
{"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, "", false},
|
||||
{"mojang admin is reclaimed", "admin", authSourceMojang, "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO", false},
|
||||
{"sso admin without local password is protected", "admin", authSourceThirdParty, "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.staff["holder"] = &StaffUser{ID: "h-1", Username: "holder", Role: tc.role, PasswordHash: tc.passHash}
|
||||
repo.links[squatter] = "h-1"
|
||||
repo.linkAuthSource[squatter] = tc.auth
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
ih := api.InternalHandler()
|
||||
|
||||
body := `{"squatter_uuid":"` + squatter + `","username":"Holder"}`
|
||||
w := do(ih, "POST", "/api/v1/internal/player/reclaim", body, nil)
|
||||
|
||||
if tc.protected {
|
||||
if w.Code != http.StatusConflict || decodeErr(t, w) != "protected_admin" {
|
||||
t.Fatalf("code = %d body %s, want 409 protected_admin", w.Code, w.Body.String())
|
||||
}
|
||||
if len(repo.blacklist) != 0 || len(repo.holds) != 0 {
|
||||
t.Fatal("a protected holder must not be barred or stashed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d body %s, want 200 (reclaim should proceed)", w.Code, w.Body.String())
|
||||
}
|
||||
if !repo.blacklist[squatter] {
|
||||
t.Fatal("an unprotected squatter must be barred — Mojang priority still holds")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReclaimIsIdempotent proves a retried velocity callback is harmless: a repeat
|
||||
// reclaim of an already-barred UUID still answers 200 and does not disturb the
|
||||
// original hold (matching the ON CONFLICT DO NOTHING in both inserts).
|
||||
|
||||
@@ -515,6 +515,24 @@ func (p *PGRepo) IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// IsProtectedAdminLink reports whether mc_uuid belongs to a Linked Operator/SysAdmin
|
||||
// who authenticates through the third-party Yggdrasil — the admin-on-Yggdrasil reclaim
|
||||
// exception (spec §B3). The EXISTS joins account_links to users on exactly three
|
||||
// conjuncts: the UUID is linked, that link authenticated via 'thirdparty', and the
|
||||
// linked user is an admin. It intentionally does not test password_hash: an Operator
|
||||
// who signs in via SSO (Cloudflare Access, §14) carries role='admin' with a NULL hash
|
||||
// and must be protected just the same — the hash is orthogonal to "is staff" and "logs
|
||||
// in via the Login Server". Keyed by UUID, the only identity velocity holds.
|
||||
func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error) {
|
||||
var ok bool
|
||||
err := p.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM account_links al JOIN users u ON u.id = al.user_id
|
||||
WHERE al.mc_uuid = $1 AND al.auth_source = 'thirdparty' AND u.role = 'admin')`,
|
||||
mcUUID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// ---- local-password auth (spec §B) ----
|
||||
|
||||
// UserByUsername loads a staff login projection by username, or ErrNotFound. A
|
||||
|
||||
@@ -218,6 +218,22 @@ type Repo interface {
|
||||
// reject a squatter while letting the genuine Mojang UUID — same username,
|
||||
// different UUID — through: the check is keyed by UUID, never by the name.
|
||||
IsUsernameBlacklisted(ctx context.Context, mcUUID string) (bool, error)
|
||||
// IsProtectedAdminLink reports whether an in-game UUID belongs to a Linked
|
||||
// Operator/SysAdmin who authenticates through the configured third-party
|
||||
// Yggdrasil — the admin-on-Yggdrasil reclaim exception (spec §B3). Such a holder
|
||||
// is staff logging in via the Login Server, not a Mojang squatter, so a
|
||||
// Mojang-priority reclaim must never bar them. The predicate is exactly three
|
||||
// conjuncts: the UUID is linked (account_links), that link authenticated via
|
||||
// 'thirdparty' (auth_source), and the linked user is an admin (role='admin').
|
||||
// It deliberately does NOT require a local password hash: an Operator who signs
|
||||
// in through SSO (Cloudflare Access, IdP-agnostic per §14) carries role='admin'
|
||||
// with no password_hash, and must be protected all the same — a password hash is
|
||||
// orthogonal to both "is staff" and "logs in via the Login Server". An unlinked
|
||||
// UUID, a Mojang-sourced link, or a non-admin link all yield false, so the
|
||||
// exception never broadens to ordinary thirdparty players (Mojang priority still
|
||||
// displaces them) nor to Mojang-authenticated identities (who have no Login-Server
|
||||
// name to protect). Keyed by UUID — the only identity velocity knows.
|
||||
IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool, error)
|
||||
|
||||
// ---- local-password auth (spec §B) ----
|
||||
|
||||
|
||||
+18
-16
@@ -84,22 +84,23 @@ func clearSessionCookie(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// hostIsAdminConsole reports whether the request arrived on the operator console
|
||||
// host, op.console.<root_domain>. The session cookie is host-only, so a session
|
||||
// minted on op.console is structurally unable to reach the player console; this
|
||||
// is the local-auth analogue of the admin Access path. The Host the API sees must
|
||||
// be the real client Host (the ingress must forward it), which the VM check
|
||||
// verifies.
|
||||
func hostIsAdminConsole(r *http.Request, rootDomain string) bool {
|
||||
if rootDomain == "" {
|
||||
return false
|
||||
// hostIsAdminConsole reports whether the request arrived on the configured
|
||||
// operator console host. The session cookie is host-only, so a session minted on
|
||||
// the admin host is structurally unable to reach the player console. If older
|
||||
// configs omit [auth].admin_hostname, fall back to op.console.<root_domain>.
|
||||
func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool {
|
||||
want := strings.TrimSpace(adminHostname)
|
||||
if want == "" {
|
||||
if rootDomain == "" {
|
||||
return false
|
||||
}
|
||||
want = "op.console." + rootDomain
|
||||
}
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
want := "op.console." + rootDomain
|
||||
return strings.EqualFold(strings.TrimSuffix(host, "."), want)
|
||||
return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, "."))
|
||||
}
|
||||
|
||||
// SessionAuth is the composite ExternalAuth for the web face. It prefers a
|
||||
@@ -113,10 +114,11 @@ func hostIsAdminConsole(r *http.Request, rootDomain string) bool {
|
||||
// rejected and does NOT fall through to the JWT delegate, so a stale or
|
||||
// forged cookie can never be laundered into a JWT attempt.
|
||||
type SessionAuth struct {
|
||||
Repo Repo
|
||||
Delegate ExternalAuth
|
||||
RootDomain string
|
||||
Now func() time.Time
|
||||
Repo Repo
|
||||
Delegate ExternalAuth
|
||||
RootDomain string
|
||||
AdminHostname string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s SessionAuth) now() time.Time {
|
||||
@@ -152,7 +154,7 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
|
||||
UserID: u.ID,
|
||||
Email: u.Email,
|
||||
Role: u.Role,
|
||||
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain),
|
||||
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+105
-1
@@ -153,6 +153,12 @@ func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessAppl
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps", r.AccountID), app, &resp); err != nil {
|
||||
// If application already exists, look it up instead of failing (idempotency)
|
||||
if strings.Contains(err.Error(), "application_already_exists") || strings.Contains(err.Error(), "11010") {
|
||||
if id, aud, lerr := r.lookupAccessApplication(ctx, app.Domain); lerr == nil && id != "" {
|
||||
return id, aud, nil
|
||||
}
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
return resp.Result.ID, resp.Result.AUD, nil
|
||||
@@ -160,7 +166,14 @@ func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessAppl
|
||||
|
||||
// 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)
|
||||
if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps/%s/policies", r.AccountID, appID), policy, nil); err != nil {
|
||||
// If policy already exists, treat it as idempotent success
|
||||
if strings.Contains(err.Error(), "policy_already_exists") || strings.Contains(err.Error(), "11015") || strings.Contains(err.Error(), "already_exists") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCloudflared executes the cloudflared binary with the given args, returning
|
||||
@@ -202,6 +215,25 @@ func (r *ExecRunner) apiPost(ctx context.Context, path string, body, out any) er
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("cfsetup: Cloudflare API authentication failed (status 401). Please verify that:\n"+
|
||||
" 1. The API Token is valid, active, and has not expired.\n"+
|
||||
" 2. You did not enter a Global API Key (a Bearer API Token is required).\n"+
|
||||
" 3. The token has the required permissions under the Account scope:\n"+
|
||||
" - Account > Access Apps and Policies: Edit\n"+
|
||||
" - Account > Cloudflare Tunnel: Edit\n"+
|
||||
" - Zone > DNS: Edit\n"+
|
||||
" Original error: %s", string(raw))
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("cfsetup: Cloudflare API access forbidden (status 403). Please verify that:\n"+
|
||||
" 1. The API Token has permission to access Account ID %q.\n"+
|
||||
" 2. The token has the required permissions under the Account scope:\n"+
|
||||
" - Account > Access Apps and Policies: Edit\n"+
|
||||
" - Account > Cloudflare Tunnel: Edit\n"+
|
||||
" - Zone > DNS: Edit\n"+
|
||||
" Original error: %s", r.AccountID, string(raw))
|
||||
}
|
||||
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
|
||||
@@ -220,3 +252,75 @@ func (r *ExecRunner) apiPost(ctx context.Context, path string, body, out any) er
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiGet sends an authenticated JSON GET 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) apiGet(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.apiBase()+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+r.APIToken)
|
||||
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 {
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("cfsetup: Cloudflare API authentication failed (status 401). Please verify that:\n"+
|
||||
" 1. The API Token is valid, active, and has not expired.\n"+
|
||||
" 2. You did not enter a Global API Key (a Bearer API Token is required).\n"+
|
||||
" 3. The token has the required permissions under the Account scope:\n"+
|
||||
" - Account > Access Apps and Policies: Edit\n"+
|
||||
" - Account > Cloudflare Tunnel: Edit\n"+
|
||||
" - Zone > DNS: Edit\n"+
|
||||
" Original error: %s", string(raw))
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("cfsetup: Cloudflare API access forbidden (status 403). Please verify that:\n"+
|
||||
" 1. The API Token has permission to access Account ID %q.\n"+
|
||||
" 2. The token has the required permissions under the Account scope:\n"+
|
||||
" - Account > Access Apps and Policies: Edit\n"+
|
||||
" - Account > Cloudflare Tunnel: Edit\n"+
|
||||
" - Zone > DNS: Edit\n"+
|
||||
" Original error: %s", r.AccountID, string(raw))
|
||||
}
|
||||
return fmt.Errorf("cfsetup: Cloudflare API %s: status %d: %s", path, resp.StatusCode, string(raw))
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// lookupAccessApplication finds an existing Access application's id and aud by domain.
|
||||
func (r *ExecRunner) lookupAccessApplication(ctx context.Context, domain string) (string, string, error) {
|
||||
var resp struct {
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
AUD string `json:"aud"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := r.apiGet(ctx, fmt.Sprintf("/accounts/%s/access/apps?per_page=100", r.AccountID), &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, app := range resp.Result {
|
||||
if app.Domain == domain {
|
||||
return app.ID, app.AUD, nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("cfsetup: access application for domain %q not found in list", domain)
|
||||
}
|
||||
Reference in new issue
Block a user