Add internal/cfsetup, the verifiable core of an optional one-click Cloudflare Tunnel + Access provisioning flow for the SysAdmin edge (spec §14). It is domain-agnostic (every FQDN is composed from the configured root_domain) and IdP-agnostic (any valid Access JWT aud is accepted, whichever IdP fronts it), so a SysAdmin who brings their own domain or Zero-Trust scheme stays fully supported. The load-bearing safety property is a fail-closed guard on the recommended Access policy. validateFailClosed is an allowlist that refuses any policy that could be public: a bypass/non-allow decision, an empty include, an "everyone" include not narrowed by a constraining require (include rules are OR, so "everyone" beside an identity is still public), or any include rule it cannot positively recognize as a scoped identity. Setup runs the guard before any side effect, so a public policy aborts the run with nothing created. The tunnel ingress routes only the web hostnames to the local panel origin and terminates in the mandatory fail-shut 404 catch-all; the raw game host is never proxied. Gating preconditions (cloudflared present, tunnel login completed, API token) are hard checks with no side effects on failure. The actual cloudflared exec, DNS routing, and Access API calls live in runner.go and are integration-only: they require the operator's own live Cloudflare account and interactive browser consent, which cannot be unit-tested. The policy guard, ingress generation, request bodies, and gating are unit-tested.
223 lines
7.8 KiB
Go
223 lines
7.8 KiB
Go
package cfsetup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// This file is INTEGRATION-ONLY. ExecRunner shells out to the real `cloudflared`
|
|
// binary and calls the live Cloudflare API; none of it can run — or be honestly
|
|
// faked — on a box without the operator's own Cloudflare account and the
|
|
// interactive `cloudflared tunnel login` consent already completed. The orchestration
|
|
// that uses it (Setup) and the request-body/guard logic are unit-verified in
|
|
// cfsetup.go; what lives here is exercised only against a real account.
|
|
|
|
const defaultAPIBase = "https://api.cloudflare.com/client/v4"
|
|
|
|
// DetectPreconditions inspects the local environment for the gating facts Setup
|
|
// needs: whether cloudflared is installed and whether the operator has logged in
|
|
// (cert.pem present). The API token is supplied by the caller (the TUI prompts
|
|
// for it); it is passed through so the returned value is ready to hand to Setup.
|
|
// This only READS the environment — it performs no Cloudflare side effects — but
|
|
// it touches the real filesystem/PATH, so it is integration-side.
|
|
func DetectPreconditions(apiToken string) Preconditions {
|
|
pre := Preconditions{APIToken: apiToken}
|
|
if path, err := exec.LookPath("cloudflared"); err == nil {
|
|
pre.CloudflaredPath = path
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if _, err := os.Stat(filepath.Join(home, ".cloudflared", "cert.pem")); err == nil {
|
|
pre.CertExists = true
|
|
}
|
|
}
|
|
return pre
|
|
}
|
|
|
|
// ExecRunner is the production Runner: cloudflared via os/exec for the tunnel and
|
|
// the Cloudflare API via HTTP for Access.
|
|
type ExecRunner struct {
|
|
// Cloudflared is the resolved cloudflared binary path (Preconditions.CloudflaredPath).
|
|
Cloudflared string
|
|
// APIToken authenticates the Access API calls (Bearer).
|
|
APIToken string
|
|
// AccountID is the Cloudflare account the Access app/policy are created under.
|
|
AccountID string
|
|
// APIBase defaults to the public Cloudflare API; overridable for testing.
|
|
APIBase string
|
|
// HTTP is the client used for API calls; nil means a default with a timeout.
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func (r *ExecRunner) httpClient() *http.Client {
|
|
if r.HTTP != nil {
|
|
return r.HTTP
|
|
}
|
|
return &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
|
|
func (r *ExecRunner) apiBase() string {
|
|
if r.APIBase != "" {
|
|
return r.APIBase
|
|
}
|
|
return defaultAPIBase
|
|
}
|
|
|
|
// tunnelIDRE extracts the UUID cloudflared prints when a tunnel is created or
|
|
// already exists.
|
|
var tunnelIDRE = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
|
|
|
|
// CreateTunnel runs `cloudflared tunnel create <name>`. cloudflared writes the
|
|
// credentials JSON under ~/.cloudflared/<id>.json and prints the id; we parse it
|
|
// out. If the tunnel already exists this returns its id (idempotent re-run).
|
|
func (r *ExecRunner) CreateTunnel(ctx context.Context, name string) (string, string, error) {
|
|
out, err := r.runCloudflared(ctx, "tunnel", "create", name)
|
|
if err != nil {
|
|
// An "already exists" is not fatal — recover the id via `tunnel list`.
|
|
if id, lerr := r.lookupTunnel(ctx, name); lerr == nil && id != "" {
|
|
return id, r.credentialsPath(id), nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
id := tunnelIDRE.FindString(out)
|
|
if id == "" {
|
|
return "", "", fmt.Errorf("cfsetup: could not parse tunnel id from cloudflared output: %s", out)
|
|
}
|
|
return id, r.credentialsPath(id), nil
|
|
}
|
|
|
|
// lookupTunnel finds an existing tunnel's id by name via `tunnel list`.
|
|
func (r *ExecRunner) lookupTunnel(ctx context.Context, name string) (string, error) {
|
|
out, err := r.runCloudflared(ctx, "tunnel", "list", "--name", name, "--output", "json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var tunnels []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &tunnels); err != nil {
|
|
return "", err
|
|
}
|
|
for _, t := range tunnels {
|
|
if t.Name == name {
|
|
return t.ID, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("cfsetup: tunnel %q not found", name)
|
|
}
|
|
|
|
func (r *ExecRunner) credentialsPath(id string) string {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, ".cloudflared", id+".json")
|
|
}
|
|
return id + ".json"
|
|
}
|
|
|
|
// RouteDNS runs `cloudflared tunnel route dns <tunnelID> <hostname>`, creating the
|
|
// proxied CNAME. It is idempotent on cloudflared's side for an existing record.
|
|
func (r *ExecRunner) RouteDNS(ctx context.Context, tunnelID, hostname string) error {
|
|
_, err := r.runCloudflared(ctx, "tunnel", "route", "dns", tunnelID, hostname)
|
|
if err != nil && strings.Contains(err.Error(), "already exists") {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// WriteTunnelConfig writes the rendered config.yml, creating its parent directory.
|
|
func (r *ExecRunner) WriteTunnelConfig(path string, contents []byte) error {
|
|
if dir := filepath.Dir(path); dir != "" {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.WriteFile(path, contents, 0o644)
|
|
}
|
|
|
|
// CreateAccessApplication POSTs the self-hosted Access app and returns its id and
|
|
// issued aud (spec §14: the aud felis [auth] access_jwt_aud must adopt).
|
|
func (r *ExecRunner) CreateAccessApplication(ctx context.Context, app AccessApplication) (string, string, error) {
|
|
var resp struct {
|
|
Result struct {
|
|
ID string `json:"id"`
|
|
AUD string `json:"aud"`
|
|
} `json:"result"`
|
|
}
|
|
if err := r.apiPost(ctx, fmt.Sprintf("/accounts/%s/access/apps", r.AccountID), app, &resp); err != nil {
|
|
return "", "", err
|
|
}
|
|
return resp.Result.ID, resp.Result.AUD, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// runCloudflared executes the cloudflared binary with the given args, returning
|
|
// combined output. The interactive `tunnel login` browser consent is NOT done
|
|
// here — it is a separate, operator-driven step the TUI suspends to run.
|
|
func (r *ExecRunner) runCloudflared(ctx context.Context, args ...string) (string, error) {
|
|
bin := r.Cloudflared
|
|
if bin == "" {
|
|
bin = "cloudflared"
|
|
}
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
var buf bytes.Buffer
|
|
cmd.Stdout = &buf
|
|
cmd.Stderr = &buf
|
|
if err := cmd.Run(); err != nil {
|
|
return buf.String(), fmt.Errorf("cfsetup: cloudflared %s: %w: %s", strings.Join(args, " "), err, buf.String())
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// apiPost sends an authenticated JSON POST 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) apiPost(ctx context.Context, path string, body, out any) error {
|
|
payload, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.apiBase()+path, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+r.APIToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
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 {
|
|
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
|
|
// success:false even on a 200.
|
|
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
|
|
}
|