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:
flyemoji committed 2026-06-28 16:41:37 +09:00
1 parent 94a3b7b5e8
commit 9c46632929
15 files changed
+957 -126

No files matched your search

+105 -1
View File
@@ -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)
}