From e5f16828986dc429c543fe1abbbfe891217c453b Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Mon, 29 Jun 2026 20:17:17 +0800 Subject: [PATCH] refactor(deploy)!: TUI --- CONTRIBUTING.md | 20 +- Dockerfile | 31 +- bootstrap_asset.go | 20 + cmd/felis/api.go | 27 +- cmd/felis/bootstrap_assets.go | 25 + cmd/felis/breakglass.go | 935 +--------------------------- cmd/felis/breakglass_test.go | 240 ------- cmd/felis/manifests.go | 6 + cmd/felis/restore.go | 6 +- cmd/felis/run.go | 4 +- cmd/felis/setup.go | 236 +++++-- cmd/felis/setup_panel.go | 108 ++++ cmd/felis/setup_test.go | 88 +++ cmd/felis/tui_bootstrap.go | 140 +++++ cmd/felis/tui_dashboard.go | 139 +++++ cmd/felis/tui_edge.go | 575 +++++++++++++++++ cmd/felis/tui_edge_apply.go | 152 +++++ cmd/felis/tui_migration.go | 142 +++++ cmd/felis/tui_owner.go | 380 +++++++++++ cmd/felis/tui_postgres.go | 260 ++++++++ cmd/felis/tui_root.go | 265 ++++++++ cmd/felis/tui_styles.go | 111 ++++ cmd/felis/tui_widgets.go | 124 ++++ deploy/bootstrap.sh | 489 ++++++++++++++- internal/api/api_test.go | 10 + internal/api/session.go | 5 + internal/cfsetup/cfsetup.go | 57 +- internal/cfsetup/cfsetup_test.go | 27 + internal/panel/panel.go | 91 +++ internal/panel/panel_test.go | 50 ++ internal/panel/static/index.html | 11 + internal/platform/bundle.go | 8 +- internal/platform/identities.go | 7 + internal/platform/workloads.go | 52 +- internal/platform/workloads_test.go | 60 +- internal/restore/jobspec.go | 15 +- internal/restore/jobspec_test.go | 9 +- 37 files changed, 3622 insertions(+), 1303 deletions(-) create mode 100644 bootstrap_asset.go create mode 100644 cmd/felis/bootstrap_assets.go create mode 100644 cmd/felis/setup_panel.go create mode 100644 cmd/felis/setup_test.go create mode 100644 cmd/felis/tui_bootstrap.go create mode 100644 cmd/felis/tui_dashboard.go create mode 100644 cmd/felis/tui_edge.go create mode 100644 cmd/felis/tui_edge_apply.go create mode 100644 cmd/felis/tui_migration.go create mode 100644 cmd/felis/tui_owner.go create mode 100644 cmd/felis/tui_postgres.go create mode 100644 cmd/felis/tui_root.go create mode 100644 cmd/felis/tui_styles.go create mode 100644 cmd/felis/tui_widgets.go create mode 100644 internal/panel/panel.go create mode 100644 internal/panel/panel_test.go create mode 100644 internal/panel/static/index.html diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9981e70..1ac2912 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,11 +175,11 @@ Run integration/deploy commands from the repository root on the Linux host: cd /path/to/Felis ``` -The one-line bootstrap script is intended for a clean Linux host, not a typical -macOS development machine: +The setup TUI is intended for a clean Linux host, not a typical macOS +development machine: ```bash -sudo -E bash deploy/bootstrap.sh +sudo felis setup ``` Useful overrides: @@ -191,16 +191,10 @@ export FELIS_IMAGE=felis:dev export FELIS_ROOT_DOMAIN=.nip.io ``` -The bootstrap flow installs/configures system services, builds the Felis image, -imports it into k3s, runs migrations, applies CRDs/manifests, and deploys the -control plane. - -After bootstrap, use the break-glass console to create or recover the Owner -account: - -```bash -sudo felis breakGlass -``` +The setup flow wraps the host bootstrap, then continues to Owner account setup +and optional Cloudflare edge setup in the same command. The raw +`deploy/bootstrap.sh` script remains available for low-level host provisioning +when debugging the installer itself. Use a VM or disposable Linux server for this. Treat it as an integration and acceptance environment, while keeping normal coding and quick tests local. diff --git a/Dockerfile b/Dockerfile index 1e026f1..8317fbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ # Felis control-plane image. # -# Builds the single multi-call `felis` binary (api / operator / migrate / reaper -# / restore / manifests / setup) as a static, CGO-free executable and ships it on -# a distroless base. Two contracts the rendered Deployments depend on: +# Builds the panel, then the single multi-call `felis` binary (api / operator / +# migrate / reaper / restore / manifests / setup) as a static, CGO-free +# executable and ships it on a distroless base. Two contracts the rendered +# Deployments depend on: # -# 1. The binary lives on PATH at /usr/local/bin/felis, because the bundle -# invokes it by bare name (`command: ["felis", ...]` in workloads.go). PATH -# is pinned explicitly so this holds regardless of base-image defaults. +# 1. The binary lives at /usr/local/bin/felis, and rendered Kubernetes +# workloads invoke that absolute path rather than relying on PATH lookup. # 2. The image is meant to be imported into a local containerd (k3s ctr import) # and referenced by a NON-:latest tag (e.g. felis:demo). k8s then resolves # the default IfNotPresent pull policy against the imported image instead of @@ -15,21 +15,30 @@ # deploy/bootstrap.sh also extracts this same binary onto the host (docker cp) # so `felis migrate up` and the `felis setup` TUI run with the identical build. +FROM node:22-bookworm AS panel +WORKDIR /panel +COPY panel/package*.json ./ +RUN npm ci +COPY panel/ ./ +RUN npm run build + FROM golang:1.26 AS build WORKDIR /src -ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +ARG TARGETOS=linux +ARG TARGETARCH # Prime the module cache first so source-only edits do not re-download deps. COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis +COPY --from=panel /panel/dist ./internal/panel/static +RUN CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="${TARGETARCH:-$(go env GOARCH)}" \ + go build -trimpath -ldflags="-s -w" -o /out/felis ./cmd/felis FROM gcr.io/distroless/static-debian12:nonroot -# Guarantee bare `felis` resolves no matter what PATH the base image ships. ENV PATH=/usr/local/bin:/usr/bin:/bin -COPY --from=build /out/felis /usr/local/bin/felis +COPY --chmod=0755 --from=build /out/felis /usr/local/bin/felis # distroless "nonroot" is uid 65532; the rendered PodSecurityContext pins # runAsUser 1000 at deploy time, and a static binary needs no /etc/passwd entry, # so either uid runs the same binary from a read-only root filesystem. USER 65532:65532 -ENTRYPOINT ["felis"] +ENTRYPOINT ["/usr/local/bin/felis"] diff --git a/bootstrap_asset.go b/bootstrap_asset.go new file mode 100644 index 0000000..6579399 --- /dev/null +++ b/bootstrap_asset.go @@ -0,0 +1,20 @@ +package felis + +import "embed" + +//go:embed deploy/bootstrap.sh +var bootstrapScript string + +//go:embed deploy/crd/*.yaml +var bootstrapAssets embed.FS + +// BootstrapScript returns the host bootstrap installer embedded in the felis binary. +func BootstrapScript() string { + return bootstrapScript +} + +// MinecraftServerCRD returns the embedded MinecraftServer CRD YAML used by the +// host bootstrap path that runs without a source checkout. +func MinecraftServerCRD() ([]byte, error) { + return bootstrapAssets.ReadFile("deploy/crd/felis.lolicon.best_minecraftservers.yaml") +} diff --git a/cmd/felis/api.go b/cmd/felis/api.go index be15e94..8b5142a 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -13,6 +13,7 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/restore" "felis.lolicon.best/internal/store" "felis.lolicon.best/internal/submit" @@ -34,9 +35,16 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") internalAddr := fs.String("internal-addr", ":8081", "internal-face listen address (service token, no Zero Trust)") + httpsAddr := fs.String("https-addr", "", "external HTTPS listen address (disabled unless --tls-cert and --tls-key are also set)") + tlsCert := fs.String("tls-cert", "", "TLS certificate path for --https-addr") + tlsKey := fs.String("tls-key", "", "TLS private key path for --https-addr") if err := fs.Parse(args); err != nil { return 2 } + if (*httpsAddr == "") != (*tlsCert == "" || *tlsKey == "") { + fmt.Fprintln(stderr, "felis api: --https-addr requires both --tls-cert and --tls-key") + return 2 + } cfg, err := config.Load(*cfgPath) if err != nil { @@ -157,13 +165,23 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { } fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)") + externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain) internalSrv := &http.Server{Addr: *internalAddr, Handler: a.InternalHandler()} - externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: a.ExternalHandler()} + externalSrv := &http.Server{Addr: cfg.Server.Listen, Handler: externalHandler} - errc := make(chan error, 2) + errc := make(chan error, 3) go func() { errc <- internalSrv.ListenAndServe() }() go func() { errc <- externalSrv.ListenAndServe() }() - fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen) + var httpsSrv *http.Server + if *httpsAddr != "" { + httpsSrv = &http.Server{Addr: *httpsAddr, Handler: externalHandler} + go func() { errc <- httpsSrv.ListenAndServeTLS(*tlsCert, *tlsKey) }() + } + if httpsSrv != nil { + fmt.Fprintf(stdout, "felis api: internal=%s external=%s https=%s\n", *internalAddr, cfg.Server.Listen, *httpsAddr) + } else { + fmt.Fprintf(stdout, "felis api: internal=%s external=%s\n", *internalAddr, cfg.Server.Listen) + } // reconcileBuilds drives the scan-gate translation: poll unfinished builds // and advance any whose Job has reached a terminal phase. GET on a build also @@ -176,6 +194,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { defer cancel() _ = internalSrv.Shutdown(shutdownCtx) _ = externalSrv.Shutdown(shutdownCtx) + if httpsSrv != nil { + _ = httpsSrv.Shutdown(shutdownCtx) + } return 0 case err := <-errc: if err != nil && err != http.ErrServerClosed { diff --git a/cmd/felis/bootstrap_assets.go b/cmd/felis/bootstrap_assets.go new file mode 100644 index 0000000..55d7baa --- /dev/null +++ b/cmd/felis/bootstrap_assets.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "io" + + felis "felis.lolicon.best" +) + +func cmdBootstrapAssets(args []string, stdout, stderr io.Writer) int { + if len(args) != 1 || args[0] != "crd" { + fmt.Fprintln(stderr, "felis bootstrap-assets: usage: felis bootstrap-assets crd") + return 2 + } + crd, err := felis.MinecraftServerCRD() + if err != nil { + fmt.Fprintf(stderr, "felis bootstrap-assets: %v\n", err) + return 1 + } + if _, err := stdout.Write(crd); err != nil { + fmt.Fprintf(stderr, "felis bootstrap-assets: write: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index c3cd887..1c2a49e 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -10,17 +10,13 @@ import ( "fmt" "io" "os" - "os/exec" "strings" "felis.lolicon.best/internal/api" - "felis.lolicon.best/internal/cfsetup" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/store" - "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "golang.org/x/crypto/bcrypt" ) @@ -129,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } - res, err := runBreakGlassTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists) + res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 @@ -401,8 +397,6 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error { }) } -// ---- interactive TUI (the untested shell over the tested core) ---- - // breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable // post-exit summary. provisioned is false on cancel. type breakGlassResult struct { @@ -415,6 +409,7 @@ type breakGlassResult struct { auditWarning string rootDomain string adminHostname string + panelURL string // optional Cloudflare edge outcome (independent of provisioned) edgeConfigured bool @@ -432,25 +427,6 @@ const ( consoleModeSetup consoleMode = "setup" ) -type bgStep int - -const ( - stepMenu bgStep = iota // top-level router: provision vs. optional edge - stepAuth // recovery: authenticate as an existing admin - stepOverride // recovery: admin auth failed → deliberate root override - stepProvision // collect the Owner target (and password, in bootstrap) - stepWorking - stepDone - stepError - // optional Cloudflare edge flow (锦上添花) - stepEdgeIntro // preconditions + interactive `cloudflared tunnel login` - stepEdgeInput // API token / account / who-to-admit / tunnel name / config path - stepEdgeWorking // cfsetup.Setup runs against the operator's Cloudflare account - stepEdgeDone - stepEdgeError -) - -// Edge-flow defaults the operator can accept as-is. const ( defaultTunnelName = "felis" defaultTunnelConfigPath = "/etc/felis/cloudflared.yml" @@ -462,901 +438,28 @@ const ( cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) -// authResultMsg carries the outcome of the off-goroutine admin credential check. -type authResultMsg struct { - matched string - ok bool - err error +func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) } -// performedMsg carries the outcome of the off-goroutine break-glass writes. -type performedMsg struct { - outcome breakGlassOutcome - err error +func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { + return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) } -// loginDoneMsg fires when the suspended `cloudflared tunnel login` returns. A -// non-nil err (or a cancelled login) returns to the intro, never an error exit — -// the operator may simply have closed the browser. -type loginDoneMsg struct { - err error -} - -// edgeDoneMsg carries the outcome of the off-goroutine cfsetup.Setup run. -type edgeDoneMsg struct { - result *cfsetup.Result - err error -} - -var ( - bgTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("88")).Padding(0, 1) - bgLabelStyle = lipgloss.NewStyle().Bold(true) - bgHintStyle = lipgloss.NewStyle().Faint(true) - bgErrStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) - bgWarnStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) - bgOKStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("10")) - bgPwStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("11")).Padding(0, 1) - bgBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(1, 3) -) - -// bgModel is the bubbletea model for the break-glass console. It is a pointer model -// so Update can mutate in place; fields touched from a background command are read -// only after that command returns via authResultMsg / performedMsg. -type bgModel struct { - ctx context.Context - store ownerStore - consoleMode consoleMode - rootDomain string - osUser string - adminExists bool - - // web hostnames sourced from [auth] config (never re-derived in the shell) — - // what the optional edge flow routes. adminHostname is required for the edge; - // panelHostname is optional. - adminHostname string - panelHostname string - - step bgStep - inputs []textinput.Model - focus int - formErr string - working string - - // resolved as the flow advances - mode string - accountable string - attemptedAdmin string - - // result - ownerUsername string - displayPassword string - auditWarning string - err error - - // optional Cloudflare edge flow - cloudflaredPath string // detected; empty = not on PATH - certExists bool // ~/.cloudflared/cert.pem present (logged in) - loginNote string // soft note after a cancelled/failed login - edgeResult *cfsetup.Result // populated on stepEdgeDone - edgePanelHostname string - edgeAdminHostname string -} - -func newBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel { - return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) -} - -func newSetupBGModel(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) *bgModel { - return newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) -} - -func newBGModelForMode(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *bgModel { - return &bgModel{ - ctx: ctx, - store: s, - consoleMode: mode, - rootDomain: rootDomain, - adminHostname: adminHostname, - panelHostname: panelHostname, - osUser: osUser, - adminExists: adminExists, - step: stepMenu, - } -} - -func (m *bgModel) Init() tea.Cmd { return textinput.Blink } - -// bgInput builds a styled text input; password fields echo a mask, never the glyphs, -// because this is typed on a shared root console. -func bgInput(placeholder string, charLimit int, password bool) textinput.Model { - ti := textinput.New() - ti.Placeholder = placeholder - ti.CharLimit = charLimit - ti.Width = 44 - ti.Prompt = "" - if password { - ti.EchoMode = textinput.EchoPassword - ti.EchoCharacter = '•' - } - return ti -} - -// setInputs installs a fresh input set, focuses the first, and returns its blink cmd. -func (m *bgModel) setInputs(ins []textinput.Model) tea.Cmd { - m.inputs = ins - m.focus = 0 - var cmd tea.Cmd - for i := range m.inputs { - if i == 0 { - cmd = m.inputs[i].Focus() - } else { - m.inputs[i].Blur() - } - } - return cmd -} - -func (m *bgModel) buildAuth() tea.Cmd { - user := bgInput("admin username", 64, false) - pass := bgInput("admin password", 128, true) - return m.setInputs([]textinput.Model{user, pass}) -} - -func (m *bgModel) buildOverride() tea.Cmd { - confirm := bgInput("type "+breakGlassOverrideToken, 16, false) - return m.setInputs([]textinput.Model{confirm}) -} - -// buildProvision installs the Owner-target inputs. withPassword adds the password + -// confirm fields used only in bootstrap mode; in recovery/override a one-time -// password is generated, so the operator does not type one. -func (m *bgModel) buildProvision(withPassword bool) tea.Cmd { - user := bgInput("owner", 64, false) - user.SetValue("owner") - email := bgInput("(optional)", 254, false) - ins := []textinput.Model{user, email} - if withPassword { - ins = append(ins, bgInput("at least 8 characters", 128, true)) - ins = append(ins, bgInput("re-enter password", 128, true)) - } - return m.setInputs(ins) -} - -func (m *bgModel) enterProvision() tea.Cmd { - m.step = stepProvision - m.formErr = "" - return m.buildProvision(m.mode == "bootstrap") -} - -// enterProvisionFlow is the menu entry into the Owner provision/reset op. It takes -// the same bootstrap-vs-recovery branch newBGModel used to take at construction: -// no admin → bootstrap the first Owner from a typed credential; an admin exists → -// authenticate first so the recovery is attributable. -func (m *bgModel) enterProvisionFlow() tea.Cmd { - m.formErr = "" - if m.adminExists { - m.step = stepAuth - return m.buildAuth() - } - m.mode = "bootstrap" - m.accountable = m.osUser - m.step = stepProvision - return m.buildProvision(true) -} - -func (m *bgModel) focusInput(i int) tea.Cmd { - if i < 0 { - i = len(m.inputs) - 1 - } - if i >= len(m.inputs) { - i = 0 - } - m.focus = i - var cmd tea.Cmd - for j := range m.inputs { - if j == i { - cmd = m.inputs[j].Focus() - } else { - m.inputs[j].Blur() - } - } - return cmd -} - -func (m *bgModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) - for i := range m.inputs { - m.inputs[i], cmds[i] = m.inputs[i].Update(msg) - } - return tea.Batch(cmds...) -} - -func (m *bgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case authResultMsg: - if msg.err != nil { - m.step, m.err = stepError, msg.err - return m, nil - } - if msg.ok { - // Verified: this admin is the accountable identity for the recovery. - m.mode = "recovery" - m.accountable = msg.matched - return m, m.enterProvision() - } - // The credential did not verify. Do NOT refuse — break-glass must still - // recover when no admin password can be produced. Offer a deliberate root - // override, attributed to the OS user and audited as unverified. - m.step = stepOverride - return m, m.buildOverride() - - case performedMsg: - if msg.err != nil { - m.step, m.err = stepError, msg.err - return m, nil - } - m.step = stepDone - m.displayPassword = msg.outcome.displayPassword - if msg.outcome.auditErr != nil { - m.auditWarning = msg.outcome.auditErr.Error() - } - return m, nil - - case loginDoneMsg: - // `cloudflared tunnel login` returned. Re-detect to pick up a freshly written - // cert.pem; a cancelled/failed login is NOT fatal — it just lands back on the - // intro with a note, because the operator may have closed the browser. - pre := cfsetup.DetectPreconditions("") - m.cloudflaredPath = pre.CloudflaredPath - m.certExists = pre.CertExists - switch { - case msg.err != nil && !m.certExists: - m.loginNote = "cloudflared login did not complete: " + msg.err.Error() - case !m.certExists: - m.loginNote = "login finished but ~/.cloudflared/cert.pem still not found — try again" - default: - m.loginNote = "" - } - m.step = stepEdgeIntro - return m, nil - - case edgeDoneMsg: - if msg.err != nil { - m.step, m.err = stepEdgeError, msg.err - return m, nil - } - m.step = stepEdgeDone - m.edgeResult = msg.result - return m, nil - - case tea.KeyMsg: - switch m.step { - case stepMenu: - return m.handleMenuKey(msg) - case stepEdgeIntro: - return m.handleEdgeIntroKey(msg) - case stepDone, stepError, stepEdgeDone, stepEdgeError: - // Any key dismisses the terminal screen. - return m, tea.Quit - case stepWorking, stepEdgeWorking: - // Ignore input while a write / setup is in flight. - return m, nil - default: - return m.handleFormKey(msg) - } - } - - return m, m.updateInputs(msg) -} - -func (m *bgModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - if m.step == stepOverride { - // Back out of the override to re-enter the admin credential. - m.step, m.formErr = stepAuth, "" - return m, m.buildAuth() - } - if m.step == stepEdgeInput { - // Back to the edge intro (re-detects preconditions), not a hard exit. - return m.enterEdgeIntro() - } - return m, tea.Quit - case "tab", "down": - return m, m.focusInput(m.focus + 1) - case "shift+tab", "up": - return m, m.focusInput(m.focus - 1) - case "enter": - return m.submit() - } - return m, m.updateInputs(msg) -} - -func (m *bgModel) submit() (tea.Model, tea.Cmd) { - switch m.step { - case stepAuth: - return m.submitAuth() - case stepOverride: - return m.submitOverride() - case stepProvision: - return m.submitProvision() - case stepEdgeInput: - return m.submitEdge() - } - return m, nil -} - -func (m *bgModel) submitAuth() (tea.Model, tea.Cmd) { - user := strings.TrimSpace(m.inputs[0].Value()) - pass := m.inputs[1].Value() - if user == "" || pass == "" { - m.formErr = "enter the username and password of an existing admin" - return m, nil - } - m.attemptedAdmin = user - m.formErr, m.working = "", "Verifying the admin credential…" - m.step = stepWorking - return m, authCmd(m.ctx, m.store, user, pass) -} - -func (m *bgModel) submitOverride() (tea.Model, tea.Cmd) { - if m.inputs[0].Value() != breakGlassOverrideToken { - m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed, or esc to go back" - return m, nil - } - m.mode = "root_override" - m.accountable = m.osUser - return m, m.enterProvision() -} - -func (m *bgModel) submitProvision() (tea.Model, tea.Cmd) { - owner := strings.TrimSpace(m.inputs[0].Value()) - if owner == "" { - m.formErr = "owner username is required" - return m, m.focusInput(0) - } - email := m.inputs[1].Value() - password := "" // empty => performBreakGlass generates a one-time password - if m.mode == "bootstrap" { - pw := m.inputs[2].Value() - confirm := m.inputs[3].Value() - if err := validateOwnerPassword(pw); err != nil { - m.formErr = err.Error() - return m, m.focusInput(2) - } - if pw != confirm { - m.formErr = "the two passwords do not match" - return m, m.focusInput(3) - } - password = pw - } - m.ownerUsername = owner - op := breakGlassOp{ - mode: m.mode, - accountable: m.accountable, - osUser: m.osUser, - ownerUsername: owner, - ownerEmail: email, - ownerPassword: password, - attemptedAdmin: m.attemptedAdmin, - } - m.formErr, m.working = "", "Provisioning the Owner account…" - m.step = stepWorking - return m, performCmd(m.ctx, m.store, op) -} - -// authCmd runs the admin credential check off the UI goroutine. -func authCmd(ctx context.Context, s ownerStore, user, pass string) tea.Cmd { - return func() tea.Msg { - matched, ok, err := authenticateAdmin(ctx, s, user, pass) - return authResultMsg{matched: matched, ok: ok, err: err} - } -} - -// performCmd runs the break-glass writes off the UI goroutine. -func performCmd(ctx context.Context, s ownerStore, op breakGlassOp) tea.Cmd { - return func() tea.Msg { - out, err := performBreakGlass(ctx, s, op) - return performedMsg{outcome: out, err: err} - } -} - -// ---- top-level router ---- - -func (m *bgModel) menuOptionCount() int { - if m.consoleMode == consoleModeSetup { - return 2 - } - return 1 -} - -func (m *bgModel) handleMenuKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - case "up", "shift+tab": - if m.focus > 0 { - m.focus-- - } - return m, nil - case "down", "tab": - if m.focus < m.menuOptionCount()-1 { - m.focus++ - } - return m, nil - case "1": - m.focus = 0 - return m.selectMenu() - case "2": - if m.menuOptionCount() > 1 { - m.focus = 1 - return m.selectMenu() - } - return m, nil - case "enter": - return m.selectMenu() - } - return m, nil -} - -func (m *bgModel) selectMenu() (tea.Model, tea.Cmd) { - if m.consoleMode == consoleModeSetup && m.focus == 1 { - return m.enterEdgeIntro() - } - if m.consoleMode == consoleModeSetup && m.adminExists { - m.formErr = "an Owner/admin already exists; use breakGlass for emergency reset, or choose Cloudflare edge" - return m, nil - } - return m, m.enterProvisionFlow() -} - -// ---- optional Cloudflare edge flow (锦上添花) ---- - -// enterEdgeIntro detects the operator-only preconditions (cloudflared on PATH, a -// completed `cloudflared tunnel login`) and shows the intro. Detection is READ-ONLY -// and re-runs every time the screen is entered so a fresh login is picked up. -func (m *bgModel) enterEdgeIntro() (tea.Model, tea.Cmd) { - m.step = stepEdgeIntro - m.formErr, m.loginNote = "", "" - pre := cfsetup.DetectPreconditions("") - m.cloudflaredPath = pre.CloudflaredPath - m.certExists = pre.CertExists - return m, nil -} - -// edgeReady reports whether the edge flow can proceed to credential entry: the -// operator must have cloudflared installed and be logged in. Hostnames are chosen -// on the next screen, so an empty [auth] hostname no longer blocks setup. -func (m *bgModel) edgeReady() bool { - return m.cloudflaredPath != "" && m.certExists -} - -func (m *bgModel) handleEdgeIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - // Back to the setup router with the edge option still highlighted. - m.step, m.focus, m.loginNote = stepMenu, 1, "" - return m, nil - case "l", "L": - // Offer the interactive login only when it is the actual blocker. - if m.cloudflaredPath != "" && !m.certExists { - return m.startCloudflaredLogin() - } - return m, nil - case "enter": - if m.edgeReady() { - return m, m.enterEdgeInput() - } - return m, nil - } - return m, nil -} - -// startCloudflaredLogin suspends the TUI to run the interactive browser consent. -// This is the one step cfsetup cannot perform or fake: it authenticates the -// operator against their OWN Cloudflare account and writes ~/.cloudflared/cert.pem. -func (m *bgModel) startCloudflaredLogin() (tea.Model, tea.Cmd) { - c := exec.CommandContext(m.ctx, m.cloudflaredPath, "tunnel", "login") - return m, tea.ExecProcess(c, func(err error) tea.Msg { - return loginDoneMsg{err: err} - }) -} - -// enterEdgeInput installs the credential/scope inputs, pre-filling safe defaults. -// Hostnames are explicit setup inputs so an operator can choose console.mc and -// op.console.mc instead of accepting whatever the bootstrap config guessed. -func (m *bgModel) enterEdgeInput() tea.Cmd { - m.step = stepEdgeInput - m.formErr = "" - token := bgInput("Cloudflare API token", 200, true) - account := bgInput("Cloudflare account ID", 64, false) - identity := bgInput("you@example.com or @your-domain", 254, false) - panelHost := bgInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false) - panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname)) - adminHost := bgInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false) - adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname)) - tunnel := bgInput(defaultTunnelName, 64, false) - tunnel.SetValue(defaultTunnelName) - cfgPath := bgInput(defaultTunnelConfigPath, 256, false) - cfgPath.SetValue(defaultTunnelConfigPath) - return m.setInputs([]textinput.Model{token, account, identity, panelHost, adminHost, tunnel, cfgPath}) -} - -// submitEdge validates the edge inputs and launches cfsetup.Setup. The fail-closed -// guard and empty-identity refusal live in cfsetup — this only translates the typed -// identity into an AccessIdentity (a leading "@" means an org email domain, else a -// specific person) and surfaces cfsetup's errors as a clean stepEdgeError. -func (m *bgModel) submitEdge() (tea.Model, tea.Cmd) { - token := strings.TrimSpace(m.inputs[0].Value()) - account := strings.TrimSpace(m.inputs[1].Value()) - identity := strings.TrimSpace(m.inputs[2].Value()) - panelHost := normalizeEdgeHostname(m.inputs[3].Value()) - adminHost := normalizeEdgeHostname(m.inputs[4].Value()) - tunnel := strings.TrimSpace(m.inputs[5].Value()) - cfgPath := strings.TrimSpace(m.inputs[6].Value()) - - if token == "" { - m.formErr = "a Cloudflare API token is required" - return m, m.focusInput(0) - } - if account == "" { - m.formErr = "the Cloudflare account ID is required" - return m, m.focusInput(1) - } - if !isHex32(account) { - if strings.HasPrefix(account, "cfat_") { - m.formErr = "you entered an API token (starting with cfat_) instead of the Cloudflare Account ID" - } else { - m.formErr = "the Cloudflare Account ID must be a 32-character hexadecimal string" - } - return m, m.focusInput(1) - } - if identity == "" { - m.formErr = "enter who Access should admit — your email, or @your-domain" - return m, m.focusInput(2) - } - // A bare "@" would scope Access to an empty email domain. cfsetup is fail-closed - // (an empty domain admits no one), but reject it here so the operator fixes the - // typo rather than silently locking everyone out. - if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" { - m.formErr = "enter a domain after the @, e.g. @your-domain" - return m, m.focusInput(2) - } - if err := validateEdgeHostname("player console hostname", panelHost, false); err != nil { - m.formErr = err.Error() - return m, m.focusInput(3) - } - if err := validateEdgeHostname("admin console hostname", adminHost, true); err != nil { - m.formErr = err.Error() - return m, m.focusInput(4) - } - if panelHost != "" && strings.EqualFold(panelHost, adminHost) { - m.formErr = "player console and admin console hostnames must be different" - return m, m.focusInput(4) - } - if tunnel == "" { - tunnel = defaultTunnelName - } - if cfgPath == "" { - cfgPath = defaultTunnelConfigPath - } - - var id cfsetup.AccessIdentity - if strings.HasPrefix(identity, "@") { - id.EmailDomains = []string{strings.TrimPrefix(identity, "@")} - } else { - id.Emails = []string{identity} - } - - m.edgePanelHostname = panelHost - m.edgeAdminHostname = adminHost - p := cfsetup.Params{ - PanelHostname: panelHost, - AdminHostname: adminHost, - TunnelName: tunnel, - ConfigPath: cfgPath, - AccessIdentity: id, - // Re-detect with the token so a cert.pem written by the in-flow login is seen. - Pre: cfsetup.DetectPreconditions(token), - } - runner := &cfsetup.ExecRunner{ - Cloudflared: m.cloudflaredPath, - APIToken: token, - AccountID: account, - } - m.formErr, m.working = "", "Configuring the Cloudflare Tunnel + Access edge…" - m.step = stepEdgeWorking - return m, edgeSetupCmd(m.ctx, runner, p) -} - -// edgeSetupCmd runs cfsetup.Setup off the UI goroutine. -func edgeSetupCmd(ctx context.Context, runner cfsetup.Runner, p cfsetup.Params) tea.Cmd { - return func() tea.Msg { - res, err := cfsetup.Setup(ctx, runner, p) - return edgeDoneMsg{result: res, err: err} - } -} - -func (m *bgModel) View() string { - var b strings.Builder - title := "FELIS BREAK-GLASS — LOCAL EMERGENCY CONSOLE" - if m.consoleMode == consoleModeSetup { - title = "FELIS SETUP — LOCAL SETUP CONSOLE" - } - b.WriteString(bgTitleStyle.Render("⚠ "+title) + "\n\n") - - switch m.step { - case stepMenu: - prompt := "Choose a break-glass operation:" - provisionTitle := "Emergency reset the Owner account" - provisionDesc := "Authenticate as an existing admin, or use a deliberate root override." - if !m.adminExists { - provisionTitle = "Create the first Owner account" - provisionDesc = "No staff account exists yet — bootstrap the first Owner." - } - opts := []struct{ title, desc string }{{provisionTitle, provisionDesc}} - if m.consoleMode == consoleModeSetup { - prompt = "Choose a setup operation:" - provisionTitle = "Create the Owner account" - provisionDesc = "No staff account exists yet — bootstrap the first Owner." - if m.adminExists { - provisionDesc = "Already exists — use breakGlass only for emergency reset." - } - opts[0] = struct{ title, desc string }{provisionTitle, provisionDesc} - opts = append(opts, struct{ title, desc string }{"Set up the Cloudflare edge", "Choose web hostnames, create Tunnel DNS, and guard the admin face with Access."}) - } - b.WriteString(prompt + "\n\n") - for i, o := range opts { - cursor, title := " ", o.title - if i == m.focus { - cursor, title = bgLabelStyle.Render(" ▸ "), bgLabelStyle.Render(o.title) - } - b.WriteString(fmt.Sprintf("%s%d. %s\n", cursor, i+1, title)) - b.WriteString(" " + bgHintStyle.Render(o.desc) + "\n\n") - } - if m.formErr != "" { - b.WriteString(bgWarnStyle.Render(m.formErr) + "\n\n") - } - hint := "↑↓ move · 1 select · enter confirm · esc exit" - if m.menuOptionCount() > 1 { - hint = "↑↓ move · 1/2 select · enter confirm · esc exit" - } - b.WriteString(bgHintStyle.Render(hint) + "\n") - - case stepAuth: - b.WriteString("A staff account already exists. Identify yourself before breaking the glass.\n") - b.WriteString("Authenticate as an existing admin — this records WHO performed the recovery.\n") - b.WriteString(bgHintStyle.Render("Best-effort attribution, not a second authority gate (root already let you in).") + "\n\n") - b.WriteString(bgLabelStyle.Render("Admin username") + "\n") - b.WriteString(m.inputs[0].View() + "\n\n") - b.WriteString(bgLabelStyle.Render("Admin password") + "\n") - b.WriteString(m.inputs[1].View() + "\n\n") - if m.formErr != "" { - b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") - } - b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter verify · esc cancel") + "\n") - - case stepOverride: - b.WriteString(bgErrStyle.Render("✗ That credential did not match any admin account.") + "\n\n") - b.WriteString("You can still proceed under local-root authority. This is a ROOT OVERRIDE:\n") - b.WriteString("it will be recorded as an UNVERIFIED break-glass attributed to the OS user\n") - b.WriteString(bgLabelStyle.Render("\""+m.osUser+"\"") + ", not to a verified admin.\n\n") - b.WriteString(bgLabelStyle.Render("Type "+breakGlassOverrideToken+" to proceed") + "\n") - b.WriteString(m.inputs[0].View() + "\n\n") - if m.formErr != "" { - b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") - } - b.WriteString(bgHintStyle.Render("enter confirm · esc go back to admin login") + "\n") - - case stepProvision: - if m.mode == "bootstrap" { - b.WriteString("No staff account exists yet — bootstrapping the first Owner.\n") - b.WriteString("You are recorded as OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + ".\n\n") - } else if m.mode == "root_override" { - b.WriteString(bgWarnStyle.Render("ROOT OVERRIDE") + " by OS user " + bgLabelStyle.Render("\""+m.osUser+"\"") + " — resetting the Owner account.\n") - b.WriteString("A new one-time password will be generated and shown once.\n\n") - } else { - b.WriteString("Authenticated as admin " + bgLabelStyle.Render("\""+m.accountable+"\"") + " — resetting the Owner account.\n") - b.WriteString("A new one-time password will be generated and shown once.\n\n") - } - b.WriteString(bgLabelStyle.Render("Owner username") + "\n") - b.WriteString(m.inputs[0].View() + "\n\n") - b.WriteString(bgLabelStyle.Render("Owner email (optional)") + "\n") - b.WriteString(m.inputs[1].View() + "\n\n") - if m.mode == "bootstrap" { - b.WriteString(bgLabelStyle.Render("Owner password") + bgHintStyle.Render(" (you will change it on first login)") + "\n") - b.WriteString(m.inputs[2].View() + "\n\n") - b.WriteString(bgLabelStyle.Render("Confirm password") + "\n") - b.WriteString(m.inputs[3].View() + "\n\n") - } - if m.formErr != "" { - b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") - } - b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter provision · esc cancel") + "\n") - - case stepEdgeIntro: - b.WriteString(bgLabelStyle.Render("Cloudflare Tunnel + Access edge") + bgHintStyle.Render(" (optional)") + "\n") - b.WriteString("Publishes the web faces over a Cloudflare Tunnel and fronts the SysAdmin\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(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") - } - if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" { - b.WriteString(" • " + admin + bgHintStyle.Render(" (Operator + SysAdmin console — Access-guarded)") + "\n") - } - b.WriteString("\n") - - if m.cloudflaredPath == "" { - b.WriteString(bgErrStyle.Render("✗ cloudflared not found on PATH") + " — install it, then esc and re-enter.\n") - } else { - b.WriteString(bgOKStyle.Render("✓ cloudflared") + " " + bgHintStyle.Render(m.cloudflaredPath) + "\n") - if m.certExists { - b.WriteString(bgOKStyle.Render("✓ logged in") + bgHintStyle.Render(" (~/.cloudflared/cert.pem present)") + "\n") - } else { - b.WriteString(bgWarnStyle.Render("• not logged in to Cloudflare") + " — press " + bgLabelStyle.Render("l") + " to run `cloudflared tunnel login`\n") - b.WriteString(bgHintStyle.Render(" (opens a browser for consent on your own account).") + "\n") - } - } - if m.loginNote != "" { - b.WriteString("\n" + bgWarnStyle.Render(m.loginNote) + "\n") - } - b.WriteString("\n") - switch { - case m.edgeReady(): - b.WriteString(bgHintStyle.Render("enter continue · esc back") + "\n") - case m.cloudflaredPath != "" && !m.certExists: - b.WriteString(bgHintStyle.Render("l login · esc back") + "\n") - default: - b.WriteString(bgHintStyle.Render("esc back") + "\n") - } - - case stepEdgeInput: - b.WriteString(bgLabelStyle.Render("Cloudflare edge · credentials & scope") + "\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", - "Admit (your email, or @your-domain)", - "Player console hostname", - "Admin console hostname", - "Tunnel name", - "Tunnel config path", - } - for i, lbl := range labels { - b.WriteString(bgLabelStyle.Render(lbl) + "\n") - b.WriteString(m.inputs[i].View() + "\n\n") - } - if m.formErr != "" { - b.WriteString(bgErrStyle.Render(m.formErr) + "\n\n") - } - b.WriteString(bgHintStyle.Render("tab/↑↓ move · enter configure · esc back") + "\n") - - case stepWorking, stepEdgeWorking: - msg := m.working - if msg == "" { - msg = "Working…" - } - b.WriteString(msg + "\n") - - case stepDone: - b.WriteString(bgOKStyle.Render("✓ Owner provisioned · local-password login ENABLED") + "\n\n") - box := bgLabelStyle.Render("username ") + m.ownerUsername - if m.displayPassword != "" { - box += "\n" + bgLabelStyle.Render("password ") + bgPwStyle.Render(m.displayPassword) - } - b.WriteString(bgBoxStyle.Render(box) + "\n\n") - b.WriteString(bgHintStyle.Render("recorded as "+m.accountable+" · mode "+m.mode+" · os user "+m.osUser) + "\n\n") - if m.displayPassword != "" { - b.WriteString(bgErrStyle.Render("Record this password now — it is shown only once.") + "\n") - } else { - b.WriteString("Log in with the password you just entered.\n") - } - b.WriteString("You will be required to change it on first login.\n\n") - if m.auditWarning != "" { - b.WriteString(bgWarnStyle.Render("⚠ accountability record was NOT written: "+m.auditWarning) + "\n\n") - } - if url := adminLoginURL(m.rootDomain, m.adminHostname); url != "" { - b.WriteString("Log in at " + bgLabelStyle.Render(url) + "\n\n") - } - b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") - - case stepEdgeDone: - b.WriteString(bgOKStyle.Render("✓ Cloudflare Tunnel + Access edge configured") + "\n\n") - var box string - if m.edgeResult != nil { - box = bgLabelStyle.Render("access_jwt_aud ") + bgPwStyle.Render(m.edgeResult.AccessAud) - if len(m.edgeResult.RoutedHostnames) > 0 { - box += "\n" + bgLabelStyle.Render("routed ") + strings.Join(m.edgeResult.RoutedHostnames, ", ") - } - if m.edgeResult.ConfigPath != "" { - box += "\n" + bgLabelStyle.Render("tunnel config ") + m.edgeResult.ConfigPath - } - } - b.WriteString(bgBoxStyle.Render(box) + "\n\n") - b.WriteString(bgWarnStyle.Render("ACTION REQUIRED") + " — make felis-api trust the edge in felis.toml:\n") - if m.edgePanelHostname != "" { - b.WriteString("set " + bgLabelStyle.Render("[auth] panel_hostname") + " to " + bgLabelStyle.Render(m.edgePanelHostname) + "\n") - } - if m.edgeAdminHostname != "" { - b.WriteString("set " + bgLabelStyle.Render("[auth] admin_hostname") + " to " + bgLabelStyle.Render(m.edgeAdminHostname) + "\n") - } - b.WriteString("set " + bgLabelStyle.Render("[auth] access_jwt_aud") + " to the value above, then start the\n") - b.WriteString("tunnel with " + bgLabelStyle.Render("cloudflared tunnel run") + ".\n\n") - b.WriteString(bgHintStyle.Render("Verify the Access app actually guards the admin face before relying on it.") + "\n\n") - b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") - - case stepError, stepEdgeError: - header := "✗ Break-glass failed" - if m.step == stepEdgeError { - header = "✗ Cloudflare edge setup failed — no usable edge was created" - } - b.WriteString(bgErrStyle.Render(header) + "\n\n") - b.WriteString(m.err.Error() + "\n\n") - b.WriteString(bgHintStyle.Render("press any key to exit") + "\n") - } - return b.String() -} - -// runBreakGlassTUI drives the emergency bubbletea program and projects the final -// model onto a breakGlassResult. The owner/auth logic is unit-tested directly. -func runBreakGlassTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass) -} - -// runSetupTUI drives the normal first-run setup console. It shares the model with -// breakGlass but starts it in setup mode, where Cloudflare edge setup is available -// and emergency Owner reset is not. -func runSetupTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) { - return runConsoleTUI(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup) -} - -func runConsoleTUI(ctx context.Context, s ownerStore, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { - final, err := tea.NewProgram(newBGModelForMode(ctx, s, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode), tea.WithAltScreen()).Run() +func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { + rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode) + final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err } - m, ok := final.(*bgModel) + root, ok := final.(*rootModel) if !ok { - return breakGlassResult{}, errors.New("unexpected final model") + return breakGlassResult{}, errors.New("unexpected final model type") } - // A terminal-error screen for either flow surfaces as a returned error. - if m.step == stepError || m.step == stepEdgeError { - return breakGlassResult{}, m.err + if root.err != nil { + return breakGlassResult{}, root.err } - res := breakGlassResult{ - provisioned: m.step == stepDone, - mode: m.mode, - accountable: m.accountable, - osUser: m.osUser, - username: m.ownerUsername, - displayPassword: m.displayPassword, - auditWarning: m.auditWarning, - rootDomain: rootDomain, - adminHostname: adminHostname, - } - if m.step == stepEdgeDone && m.edgeResult != nil { - res.edgeConfigured = true - res.edgeAud = m.edgeResult.AccessAud - res.edgeRoutedHosts = m.edgeResult.RoutedHostnames - res.edgeConfigPath = m.edgeResult.ConfigPath - res.edgePanelHostname = m.edgePanelHostname - res.edgeAdminHostname = m.edgeAdminHostname - } - return res, nil + return root.result, nil } func defaultPanelHostname(rootDomain, configured string) string { @@ -1414,3 +517,13 @@ func isHex32(s string) bool { } return true } + +func adminLoginURL(rootDomain, adminHostname string) string { + if h := strings.TrimSpace(adminHostname); h != "" { + return "https://" + h + } + if rootDomain != "" { + return "https://op.console." + rootDomain + } + return "" +} diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index 4958eee..d2cb26d 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -9,7 +9,6 @@ import ( "felis.lolicon.best/internal/api" - tea "github.com/charmbracelet/bubbletea" "golang.org/x/crypto/bcrypt" ) @@ -508,242 +507,3 @@ func TestAccountableOSUser(t *testing.T) { } }) } - -// testRoot is the sanctioned placeholder root domain for tests (never a real host). -const testRoot = "mc.example.net" - -// advance feeds one message to the model and returns it re-typed as *bgModel, so the -// state-machine assertions can read the resolved fields. The returned cmd is dropped: -// these tests drive the gating transitions directly (authResultMsg / key presses) -// rather than running the off-goroutine store commands. -func advance(t *testing.T, m *bgModel, msg tea.Msg) *bgModel { - t.Helper() - next, _ := m.Update(msg) - bm, ok := next.(*bgModel) - if !ok { - t.Fatalf("Update returned %T, want *bgModel", next) - } - return bm -} - -// enterProvisionViaMenu drives the top-level router into the Owner provision/reset -// flow the way an operator does on the emergency path: the menu opens with option 1 -// (provision) focused, so a single Enter selects it. The gating sub-tests use this to -// reach stepAuth (recovery) or stepProvision (bootstrap) through the REAL entry path -// before asserting the accountability transitions — not by reaching past the menu. -func enterProvisionViaMenu(t *testing.T, m *bgModel) *bgModel { - t.Helper() - if m.step != stepMenu { - t.Fatalf("expected the model to open on stepMenu, got %v", m.step) - } - return advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) -} - -// TestBGModelGating drives the break-glass state machine headlessly to lock in the -// accountability gate: a credential never advances to provisioning without either a -// verified admin (recovery) or a deliberate, explicit OVERRIDE (root override), and -// the resolved actor matches the path taken. This is the "which SysAdmin" guarantee. -func TestBGModelGating(t *testing.T) { - ctx := context.Background() - - t.Run("recovery starts at auth; a non-matching credential offers override, never provision", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m = enterProvisionViaMenu(t, m) - if m.step != stepAuth || m.mode != "" { - t.Fatalf("initial step/mode = %v/%q, want stepAuth and an unresolved mode", m.step, m.mode) - } - m = advance(t, m, authResultMsg{ok: false}) - if m.step != stepOverride { - t.Errorf("after a non-matching credential step = %v, want stepOverride", m.step) - } - if m.mode == "recovery" { - t.Error("mode must NOT become recovery on a failed credential — that would forge attribution") - } - }) - - t.Run("recovery with a verified admin enters provision attributed to that admin", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m = enterProvisionViaMenu(t, m) - m = advance(t, m, authResultMsg{matched: "bob", ok: true}) - if m.step != stepProvision { - t.Fatalf("step = %v, want stepProvision", m.step) - } - if m.mode != "recovery" || m.accountable != "bob" { - t.Errorf("mode/accountable = %q/%q, want recovery/bob (the verified admin, not the OS user)", m.mode, m.accountable) - } - // Recovery generates the one-time password, so no password fields are shown. - if len(m.inputs) != 2 { - t.Errorf("recovery provision inputs = %d, want 2 (owner, email — no typed password)", len(m.inputs)) - } - }) - - t.Run("an auth lookup error surfaces an error screen, not a silent override", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m = enterProvisionViaMenu(t, m) - m = advance(t, m, authResultMsg{err: errors.New("db unreachable")}) - if m.step != stepError || m.err == nil { - t.Errorf("step/err = %v/%v, want stepError with a non-nil err", m.step, m.err) - } - }) - - t.Run("the root override requires the exact OVERRIDE token", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m = enterProvisionViaMenu(t, m) - m = advance(t, m, authResultMsg{ok: false}) // → stepOverride - m.inputs[0].SetValue("override") // wrong case must not pass - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepOverride || m.formErr == "" { - t.Errorf("wrong token: step/formErr = %v/%q, want stay on stepOverride with an error", m.step, m.formErr) - } - if m.mode == "root_override" { - t.Error("mode must not flip to root_override without the exact token") - } - m.inputs[0].SetValue(breakGlassOverrideToken) - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepProvision || m.mode != "root_override" || m.accountable != "alice" { - t.Errorf("after OVERRIDE: step/mode/accountable = %v/%q/%q, want stepProvision/root_override/alice (the OS user)", m.step, m.mode, m.accountable) - } - }) - - t.Run("empty admin credentials do not start a verification", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m = enterProvisionViaMenu(t, m) - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) // both inputs blank - if m.step != stepAuth || m.formErr == "" { - t.Errorf("blank submit: step/formErr = %v/%q, want stay on stepAuth with an error", m.step, m.formErr) - } - }) - - t.Run("bootstrap starts at provision as the OS user and requires a valid, matching password", func(t *testing.T) { - f := &fakeOwnerStore{} - m := newBGModel(ctx, f, testRoot, "", "", "deploybot", false) - m = enterProvisionViaMenu(t, m) - if m.step != stepProvision || m.mode != "bootstrap" || m.accountable != "deploybot" { - t.Fatalf("initial step/mode/accountable = %v/%q/%q, want stepProvision/bootstrap/deploybot", m.step, m.mode, m.accountable) - } - if len(m.inputs) != 4 { - t.Fatalf("bootstrap inputs = %d, want 4 (owner, email, password, confirm)", len(m.inputs)) - } - // Too-short password is blocked, with no writes. - m.inputs[2].SetValue("short") - m.inputs[3].SetValue("short") - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepProvision || m.formErr == "" { - t.Errorf("weak password: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr) - } - // A mismatched confirmation is blocked. - m.inputs[2].SetValue("valid-test-pw") - m.inputs[3].SetValue("valid-test-XX") - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepProvision || m.formErr == "" { - t.Errorf("mismatch: step/formErr = %v/%q, want stay on stepProvision with an error", m.step, m.formErr) - } - if len(f.upserts) != 0 { - t.Error("no owner should be provisioned while the form is invalid") - } - // Valid + matching advances to the working state (the write is dispatched). - m.inputs[3].SetValue("valid-test-pw") - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepWorking || m.ownerUsername != "owner" { - t.Errorf("valid submit: step/owner = %v/%q, want stepWorking/owner", m.step, m.ownerUsername) - } - }) -} - -// TestBGModelEdgeRouting locks in the setup-only Cloudflare edge flow WITHOUT -// touching the operator's real Cloudflare account: setup option 2 reaches the edge -// intro as an independent peer of Owner creation; breakGlass has no edge option; -// hostnames are collected in the setup form; and invalid inputs are refused before -// any cfsetup.Setup side effect. The real cloudflared/cert.pem detection and the -// integration Setup (which shells out / calls the live API) are deliberately NOT exercised. -func TestBGModelEdgeRouting(t *testing.T) { - ctx := context.Background() - - t.Run("setup menu option 2 enters the edge intro as a peer of provisioning, leaving the Owner credential untouched", func(t *testing.T) { - f := &fakeOwnerStore{admins: true} - m := newSetupBGModel(ctx, f, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true) - if m.step != stepMenu { - t.Fatalf("initial step = %v, want stepMenu", m.step) - } - m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) - if m.step != stepEdgeIntro { - t.Fatalf("after selecting option 2 step = %v, want stepEdgeIntro", m.step) - } - // Reaching the edge must NOT have provisioned or reset an Owner. - if len(f.upserts) != 0 { - t.Error("the edge flow must not write any Owner record") - } - }) - - t.Run("esc from the edge intro returns to the setup router with the edge option highlighted", func(t *testing.T) { - m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true) - m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEsc}) - if m.step != stepMenu || m.focus != 1 { - t.Errorf("after esc step/focus = %v/%d, want stepMenu with the edge option (1) focused", m.step, m.focus) - } - }) - - t.Run("breakGlass has no edge option 2", func(t *testing.T) { - m := newBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true) - m = advance(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) - if m.step != stepMenu { - t.Fatalf("breakGlass option 2 advanced to %v, want to stay on stepMenu", m.step) - } - }) - - t.Run("submitEdge refuses empty credentials before any Cloudflare side effect", func(t *testing.T) { - m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true) - // Install the edge inputs directly: reaching them via the menu requires a real - // cloudflared login (edgeReady()), which this unit test must not depend on. - m.enterEdgeInput() - if m.step != stepEdgeInput || len(m.inputs) != 7 { - t.Fatalf("enterEdgeInput: step/inputs = %v/%d, want stepEdgeInput with 7 inputs", m.step, len(m.inputs)) - } - // All inputs blank: submit (via the real key path) must report an error and stay - // put — NOT reach stepEdgeWorking, which is what launches cfsetup.Setup against - // the live cloudflared binary / Cloudflare API. - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepEdgeInput || m.formErr == "" { - t.Errorf("blank edge submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr) - } - if m.step == stepEdgeWorking { - t.Error("empty credentials must never reach stepEdgeWorking — that would invoke the integration runner") - } - }) - - t.Run("submitEdge rejects a bare @ identity that would scope Access to an empty domain", func(t *testing.T) { - m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "op.console."+testRoot, "console."+testRoot, "alice", true) - m.enterEdgeInput() - // Token + account present, but identity is a bare "@" (empty domain). This passes - // the non-empty check yet must be refused before cfsetup.Setup, because an empty - // EmailDomain admits no one — a silent lock-out the operator should fix. - m.inputs[0].SetValue("token-value") - m.inputs[1].SetValue("1234567890abcdef1234567890abcdef") - m.inputs[2].SetValue("@") - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepEdgeInput || m.formErr == "" { - t.Errorf("bare @ submit: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr) - } - if m.step == stepEdgeWorking { - t.Error("a bare @ identity must never reach stepEdgeWorking — that would invoke the integration runner") - } - }) - - t.Run("submitEdge requires an admin hostname and rejects URLs", func(t *testing.T) { - m := newSetupBGModel(ctx, &fakeOwnerStore{admins: true}, testRoot, "", "", "alice", true) - m.enterEdgeInput() - m.inputs[0].SetValue("token-value") - m.inputs[1].SetValue("1234567890abcdef1234567890abcdef") - m.inputs[2].SetValue("ops@example.net") - m.inputs[3].SetValue("https://console." + testRoot) - m.inputs[4].SetValue("") - m = advance(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.step != stepEdgeInput || m.formErr == "" { - t.Errorf("bad hostnames: step/formErr = %v/%q, want stay on stepEdgeInput with an error", m.step, m.formErr) - } - if m.step == stepEdgeWorking { - t.Error("invalid hostnames must never reach stepEdgeWorking") - } - }) -} diff --git a/cmd/felis/manifests.go b/cmd/felis/manifests.go index df3c5a9..266bc63 100644 --- a/cmd/felis/manifests.go +++ b/cmd/felis/manifests.go @@ -41,6 +41,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { buildNS := fs.String("build-namespace", platform.DefaultBuildNamespace, "namespace image-build Jobs run in") registryNS := fs.String("registry-namespace", "", "namespace of the in-cluster registry (default: control namespace)") registryPort := fs.Int("registry-port", 5000, "port the in-cluster registry listens on") + panelNodePort := fs.Int("panel-node-port", int(platform.DefaultPanelNodePort), "NodePort that exposes the built-in HTTPS panel/API origin") felisImage := fs.String("felis-image", "", "container image the felis-api/operator Deployments run, also passed through as FELIS_IMAGE (REQUIRED)") registryImage := fs.String("registry-image", "", "in-cluster registry image (default: registry:2)") backupPVC := fs.String("backup-pvc", "", "name of the backup PVC advertised to the restore executor via FELIS_BACKUP_PVC (default none = restore endpoint returns 503)") @@ -76,6 +77,10 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { return 2 } } + if *panelNodePort < 30000 || *panelNodePort > 32767 { + fmt.Fprintf(stderr, "felis manifests: --panel-node-port must be in Kubernetes NodePort range 30000-32767 (got %d)\n", *panelNodePort) + return 2 + } // Retention/reaper rendering is opt-in and needs all three storage coordinates // together: where worlds live (to read+archive them), the backup PVC (to write @@ -114,6 +119,7 @@ func cmdManifests(args []string, stdout, stderr io.Writer) int { BuildNamespace: *buildNS, RegistryNamespace: *registryNS, RegistryPort: int32(*registryPort), + PanelNodePort: int32(*panelNodePort), FelisImage: *felisImage, RegistryImage: *registryImage, BackupPVC: *backupPVC, diff --git a/cmd/felis/restore.go b/cmd/felis/restore.go index e1816ac..2e21fca 100644 --- a/cmd/felis/restore.go +++ b/cmd/felis/restore.go @@ -11,9 +11,9 @@ import ( ctrl "sigs.k8s.io/controller-runtime" ) -// cmdRestore is the in-Pod entrypoint the restore Job runs (internal/restore -// renders a Pod whose command is `felis restore`). It extracts a world archive -// from the backup mount into the world mount and exits — it is NOT a +// cmdRestore is the in-Pod entrypoint the restore Job runs. internal/restore +// renders a Pod whose command is `/usr/local/bin/felis restore`. It extracts a +// world archive from the backup mount into the world mount and exits — it is NOT a // user-facing command and is never invoked by hand. // // It deliberately holds NO database credentials and never calls config.Load: diff --git a/cmd/felis/run.go b/cmd/felis/run.go index bbbf3bb..d34925a 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -18,7 +18,7 @@ Commands: restore Extract a world archive into a world volume (internal Job entrypoint) manifests Render the control-plane RBAC + NetworkPolicy install bundle as YAML apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) - setup Open the first-run setup console (TUI; requires root/sudo) + setup Run host bootstrap + first-run setup console (TUI; requires root/sudo) breakGlass Open the local break-glass emergency console (TUI; requires root/sudo) Run "felis -h" for command-specific flags. @@ -51,6 +51,8 @@ func run(args []string, stdout, stderr io.Writer) int { return cmdSetup(rest, stdout, stderr) case "breakGlass": return cmdBreakGlass(rest, stdout, stderr) + case "bootstrap-assets": + return cmdBootstrapAssets(rest, stdout, stderr) case "-h", "--help", "help": fmt.Fprint(stdout, usage) return 0 diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 9111819..2288263 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -8,60 +8,105 @@ import ( "io" "os" "strings" + "time" "felis.lolicon.best/internal/api" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/store" ) +const defaultSetupConfigPath = "/etc/felis/felis.toml" +const hostSetupConfigPath = "/etc/felis/felis.host.toml" +const hostBootstrapDonePath = "/etc/felis/bootstrap.done" +const hostBootstrapBinPath = "/usr/local/bin/felis" +const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml" + +var errHostBootstrapCancelled = errors.New("host bootstrap cancelled") + // cmdSetup is the normal first-run operator console. It is intentionally separate // from breakGlass: setup creates the initial Owner and optional web edge; breakGlass // is reserved for emergency local recovery/reset. func cmdSetup(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("setup", flag.ContinueOnError) fs.SetOutput(stderr) - cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml") + cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 } return 2 } + configFlagSet := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "config" { + configFlagSet = true + } + }) if os.Geteuid() != 0 { fmt.Fprintln(stderr, "felis setup: refused — the setup console must run as root (try: sudo felis setup)") return 1 } - cfg, err := config.Load(*cfgPath) - if err != nil { - fmt.Fprintf(stderr, "felis setup: %v\n", err) - return 1 - } - ctx := context.Background() - drv, err := store.Open(ctx, cfg.Database.URL) - if err != nil { - fmt.Fprintf(stderr, "felis setup: open database: %v\n", err) + bootstrapped := false + if shouldRunHostBootstrapBeforeConfig(configFlagSet) { + if err := runHostBootstrapForSetup(ctx); err != nil { + return reportHostBootstrapError(err, stdout, stderr) + } + bootstrapped = true + } + if err := repairDefaultSetupConfig(configFlagSet); err != nil { + fmt.Fprintf(stderr, "felis setup: repair default config: %v\n", err) return 1 } - defer drv.Close() - - repo := api.NewPGRepo(drv.DB()) - adminExists, err := repo.AdminExists(ctx) + effectiveCfgPath := setupConfigPath(*cfgPath, configFlagSet) + setup, err := openConfiguredSetup(ctx, effectiveCfgPath) if err != nil { - fmt.Fprintf(stderr, "felis setup: detect existing admin: %v\n", err) - return 1 + if bootstrapped || !shouldRunHostBootstrap(effectiveCfgPath, configFlagSet, err) { + fmt.Fprintf(stderr, "felis setup: %v\n", err) + return 1 + } + if err := runHostBootstrapForSetup(ctx); err != nil { + return reportHostBootstrapError(err, stdout, stderr) + } + bootstrapped = true + if err := repairDefaultSetupConfig(configFlagSet); err != nil { + fmt.Fprintf(stderr, "felis setup: repair default config: %v\n", err) + return 1 + } + effectiveCfgPath = setupConfigPath(*cfgPath, configFlagSet) + setup, err = openConfiguredSetup(ctx, effectiveCfgPath) + if err != nil { + fmt.Fprintf(stderr, "felis setup: after bootstrap: %v\n", err) + return 1 + } } + defer setup.drv.Close() - res, err := runSetupTUI(ctx, repo, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists) + res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, accountableOSUser(), setup.adminExists) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 } + panelURL := res.panelURL + if panelURL == "" { + panelURL = localPanelURL(setup.cfg.Server.RootDomain) + } if !res.provisioned && !res.edgeConfigured { + if bootstrapped { + fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.") + if panelURL != "" { + fmt.Fprintf(stdout, "Panel: %s\n", panelURL) + fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.") + } + return 0 + } fmt.Fprintln(stdout, "felis setup: cancelled — no changes made.") + if panelURL != "" { + fmt.Fprintf(stdout, "Panel: %s\n", panelURL) + } return 0 } @@ -76,8 +121,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { if res.auditWarning != "" { fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) } - if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" { - fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", url) + if panelURL != "" { + fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL) + fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.") } } @@ -89,27 +135,145 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { if res.edgeConfigPath != "" { fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath) } - fmt.Fprintf(stdout, "\nACTION REQUIRED — make felis-api trust the edge:\n") - fmt.Fprintf(stdout, " in %s under [auth], set:\n", *cfgPath) - if res.edgePanelHostname != "" { - fmt.Fprintf(stdout, " panel_hostname = %q\n", res.edgePanelHostname) - } - if res.edgeAdminHostname != "" { - fmt.Fprintf(stdout, " admin_hostname = %q\n", res.edgeAdminHostname) - } - fmt.Fprintf(stdout, " access_jwt_aud = %q\n", res.edgeAud) - fmt.Fprintln(stdout, "Then start the tunnel: cloudflared tunnel run") - fmt.Fprintln(stdout, "Verify the Access app actually guards the admin face before relying on it.") + fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.") } return 0 } -func adminLoginURL(rootDomain, adminHostname string) string { - if h := strings.TrimSpace(adminHostname); h != "" { - return "https://" + h +type configuredSetup struct { + cfg *config.Config + drv *store.PostgresDriver + repo *api.PGRepo + adminExists bool +} + +type setupOpenError struct { + stage string + err error +} + +func (e *setupOpenError) Error() string { + return e.stage + ": " + e.err.Error() +} + +func (e *setupOpenError) Unwrap() error { + return e.err +} + +func openConfiguredSetup(ctx context.Context, cfgPath string) (*configuredSetup, error) { + cfg, err := config.Load(cfgPath) + if err != nil { + return nil, &setupOpenError{stage: "load config", err: err} } - if rootDomain != "" { - return "https://op.console." + rootDomain + drv, err := store.Open(ctx, cfg.Database.URL) + if err != nil { + return nil, &setupOpenError{stage: "open database", err: err} } - return "" + repo := api.NewPGRepo(drv.DB()) + adminExists, err := repo.AdminExists(ctx) + if err != nil { + drv.Close() + return nil, &setupOpenError{stage: "detect existing admin", err: err} + } + return &configuredSetup{cfg: cfg, drv: drv, repo: repo, adminExists: adminExists}, nil +} + +func setupConfigPath(requested string, configFlagSet bool) string { + return setupConfigPathFor(requested, hostSetupConfigPath, configFlagSet) +} + +func setupConfigPathFor(requested, host string, configFlagSet bool) string { + if configFlagSet { + return requested + } + if _, err := os.Stat(host); err == nil { + return host + } + return requested +} + +func repairDefaultSetupConfig(configFlagSet bool) error { + if configFlagSet { + return nil + } + if _, err := os.Stat(hostSetupConfigPath); err != nil { + return nil + } + return ensureDefaultConfigLink(defaultSetupConfigPath, hostSetupConfigPath) +} + +func ensureDefaultConfigLink(target, host string) error { + if link, err := os.Readlink(target); err == nil && link == host { + return nil + } + if _, err := os.Lstat(target); err != nil { + if errors.Is(err, os.ErrNotExist) { + return os.Symlink(host, target) + } + return err + } + backup := fmt.Sprintf("%s.bak.%s.%d", target, time.Now().UTC().Format("20060102150405"), os.Getpid()) + if err := os.Rename(target, backup); err != nil { + return err + } + return os.Symlink(host, target) +} + +func shouldRunHostBootstrap(cfgPath string, configFlagSet bool, err error) bool { + if configFlagSet { + return false + } + var setupErr *setupOpenError + if !errors.As(err, &setupErr) { + return false + } + if setupErr.stage == "open database" { + return true + } + if setupErr.stage != "load config" { + return false + } + _, statErr := os.Stat(cfgPath) + return errors.Is(statErr, os.ErrNotExist) +} + +func shouldRunHostBootstrapBeforeConfig(configFlagSet bool) bool { + if configFlagSet { + return false + } + return !hostBootstrapReady(hostBootstrapDonePath, hostSetupConfigPath, hostBootstrapBinPath, hostBootstrapKubeconfigPath) +} + +func hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig string) bool { + return fileExists(marker) && fileExists(hostConfig) && executableExists(hostBin) && fileExists(kubeconfig) +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func executableExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Mode()&0o111 != 0 +} + +func runHostBootstrapForSetup(ctx context.Context) error { + completed, err := runHostBootstrapTUI(ctx) + if err != nil { + return err + } + if !completed { + return errHostBootstrapCancelled + } + return nil +} + +func reportHostBootstrapError(err error, stdout, stderr io.Writer) int { + if errors.Is(err, errHostBootstrapCancelled) { + fmt.Fprintln(stdout, "felis setup: cancelled — bootstrap not run.") + return 0 + } + fmt.Fprintf(stderr, "felis setup: bootstrap: %v\n", err) + return 1 } diff --git a/cmd/felis/setup_panel.go b/cmd/felis/setup_panel.go new file mode 100644 index 0000000..e362d92 --- /dev/null +++ b/cmd/felis/setup_panel.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const defaultPanelNodePort = 30443 + +type panelAccessResult struct { + url string + err error +} + +func setupPanelNodePort() int { + raw := strings.TrimSpace(os.Getenv("FELIS_PANEL_NODEPORT")) + if raw == "" { + return defaultPanelNodePort + } + port, err := strconv.Atoi(raw) + if err != nil || port < 30000 || port > 32767 { + return defaultPanelNodePort + } + return port +} + +func localPanelURL(rootDomain string) string { + if ip := rootDomainEmbeddedIP(rootDomain); ip != "" { + return fmt.Sprintf("https://%s:%d", ip, setupPanelNodePort()) + } + host := defaultAdminHostname(rootDomain, "") + if host == "" { + return "" + } + return fmt.Sprintf("https://%s:%d", host, setupPanelNodePort()) +} + +func rootDomainEmbeddedIP(rootDomain string) string { + domain := strings.TrimSpace(strings.TrimSuffix(rootDomain, ".")) + for _, suffix := range []string{".nip.io", ".sslip.io"} { + base := strings.TrimSuffix(domain, suffix) + if base == domain { + continue + } + if ip := net.ParseIP(base); ip != nil { + return ip.String() + } + } + return "" +} + +func localPanelOrigin() string { + return fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort()) +} + +func checkPanelAccess(rootDomain string) panelAccessResult { + base := localPanelURL(rootDomain) + if base == "" { + return panelAccessResult{err: fmt.Errorf("root domain is empty")} + } + hostURL, err := url.Parse(base) + if err != nil { + return panelAccessResult{url: base, err: err} + } + probeBase := fmt.Sprintf("https://127.0.0.1:%d", setupPanelNodePort()) + client := &http.Client{ + Timeout: 8 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec + } + for _, target := range []string{probeBase + "/healthz", probeBase + "/", probeBase + "/config.json"} { + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return panelAccessResult{url: base, err: err} + } + req.Host = hostURL.Hostname() + resp, err := client.Do(req) + if err != nil { + return panelAccessResult{url: base, err: err} + } + if resp.Body != nil { + defer resp.Body.Close() + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return panelAccessResult{url: base, err: fmt.Errorf("%s returned HTTP %d", target, resp.StatusCode)} + } + if strings.HasSuffix(target, "/config.json") { + var cfg struct { + APIBase string `json:"apiBase"` + RootDomain string `json:"rootDomain"` + } + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return panelAccessResult{url: base, err: fmt.Errorf("decode config.json: %w", err)} + } + if cfg.APIBase != "/api/v1" || cfg.RootDomain == "" { + return panelAccessResult{url: base, err: fmt.Errorf("config.json is incomplete")} + } + } + } + return panelAccessResult{url: base} +} diff --git a/cmd/felis/setup_test.go b/cmd/felis/setup_test.go new file mode 100644 index 0000000..4a6922c --- /dev/null +++ b/cmd/felis/setup_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetupConfigPathPrefersGeneratedHostConfig(t *testing.T) { + dir := t.TempDir() + requested := filepath.Join(dir, "felis.toml") + host := filepath.Join(dir, "felis.host.toml") + + if got := setupConfigPathFor(requested, host, false); got != requested { + t.Fatalf("without host config: got %q, want requested %q", got, requested) + } + if err := os.WriteFile(host, []byte("host"), 0o644); err != nil { + t.Fatal(err) + } + if got := setupConfigPathFor(requested, host, false); got != host { + t.Fatalf("with host config: got %q, want host %q", got, host) + } + if got := setupConfigPathFor(requested, host, true); got != requested { + t.Fatalf("explicit config: got %q, want requested %q", got, requested) + } +} + +func TestEnsureDefaultConfigLinkBacksUpStaleDefault(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "felis.toml") + host := filepath.Join(dir, "felis.host.toml") + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(host, []byte("host"), 0o644); err != nil { + t.Fatal(err) + } + + if err := ensureDefaultConfigLink(target, host); err != nil { + t.Fatal(err) + } + link, err := os.Readlink(target) + if err != nil { + t.Fatal(err) + } + if link != host { + t.Fatalf("default config link = %q, want %q", link, host) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + foundBackup := false + for _, e := range entries { + foundBackup = foundBackup || strings.HasPrefix(e.Name(), "felis.toml.bak.") + } + if !foundBackup { + t.Fatal("stale default config was not backed up") + } +} + +func TestHostBootstrapReadyRequiresMarkerAndArtifacts(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "bootstrap.done") + hostConfig := filepath.Join(dir, "felis.host.toml") + hostBin := filepath.Join(dir, "felis") + kubeconfig := filepath.Join(dir, "k3s.yaml") + + if err := os.WriteFile(marker, []byte("done"), 0o644); err != nil { + t.Fatal(err) + } + if hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) { + t.Fatal("bootstrap should not be ready with marker only") + } + + for _, path := range []string{hostConfig, kubeconfig} { + if err := os.WriteFile(path, []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(hostBin, []byte("bin"), 0o755); err != nil { + t.Fatal(err) + } + if !hostBootstrapReady(marker, hostConfig, hostBin, kubeconfig) { + t.Fatal("bootstrap should be ready when marker and host artifacts exist") + } +} diff --git a/cmd/felis/tui_bootstrap.go b/cmd/felis/tui_bootstrap.go new file mode 100644 index 0000000..792ed81 --- /dev/null +++ b/cmd/felis/tui_bootstrap.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + + felis "felis.lolicon.best" + + tea "github.com/charmbracelet/bubbletea" +) + +type hostBootstrapState int + +const ( + hostBootstrapIntro hostBootstrapState = iota + hostBootstrapRunning + hostBootstrapDone + hostBootstrapError +) + +type hostBootstrapDoneMsg struct { + err error +} + +type hostBootstrapModel struct { + ctx context.Context + state hostBootstrapState + completed bool + err error +} + +func newHostBootstrapModel(ctx context.Context) *hostBootstrapModel { + return &hostBootstrapModel{ctx: ctx} +} + +func (m *hostBootstrapModel) Init() tea.Cmd { return nil } + +func (m *hostBootstrapModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case hostBootstrapDoneMsg: + if msg.err != nil { + m.state = hostBootstrapError + m.err = msg.err + return m, nil + } + m.state = hostBootstrapDone + m.completed = true + m.err = nil + return m, nil + + case tea.KeyMsg: + switch m.state { + case hostBootstrapIntro: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + m.state = hostBootstrapRunning + m.err = nil + return m, m.runBootstrap() + } + case hostBootstrapDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, tea.Quit + } + case hostBootstrapError: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + m.state = hostBootstrapRunning + m.err = nil + return m, m.runBootstrap() + } + } + } + return m, nil +} + +func (m *hostBootstrapModel) View() string { + var b strings.Builder + b.WriteString(tuiHeader("Host Bootstrap")) + + switch m.state { + case hostBootstrapIntro: + b.WriteString(tuiHint.Render("Host bootstrap has not been marked complete.") + "\n\n") + b.WriteString(tuiInfo("The embedded installer will configure system packages, Docker, k3s, PostgreSQL, migrations, and the Felis control plane.") + "\n\n") + b.WriteString(tuiWarn.Render("Run this on a disposable Linux host or VM. It changes system services.") + "\n") + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter", "run bootstrap", "esc", "cancel")) + case hostBootstrapRunning: + b.WriteString(tuiHint.Render("Running host bootstrap...") + "\n") + b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n") + case hostBootstrapDone: + b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n") + b.WriteString(tuiInfo("Continue to create the Owner account and optional Cloudflare edge.") + "\n") + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter", "continue", "esc", "continue")) + case hostBootstrapError: + b.WriteString(tuiErrorBanner("Host bootstrap failed.") + "\n\n") + if m.err != nil { + b.WriteString(tuiHint.Render(m.err.Error()) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter", "retry", "esc", "exit")) + } + return b.String() +} + +func (m *hostBootstrapModel) runBootstrap() tea.Cmd { + exe, err := os.Executable() + if err != nil { + return func() tea.Msg { return hostBootstrapDoneMsg{err: err} } + } + cmd := exec.CommandContext(m.ctx, "bash", "-s") + cmd.Stdin = strings.NewReader(felis.BootstrapScript()) + cmd.Env = append(os.Environ(), + "FELIS_BOOTSTRAP_FROM_TUI=1", + "FELIS_BOOTSTRAP_BINARY="+exe, + ) + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return hostBootstrapDoneMsg{err: err} + }) +} + +func runHostBootstrapTUI(ctx context.Context) (bool, error) { + final, err := tea.NewProgram(newHostBootstrapModel(ctx)).Run() + if err != nil { + return false, err + } + m, ok := final.(*hostBootstrapModel) + if !ok { + return false, errors.New("unexpected bootstrap model type") + } + return m.completed, m.err +} diff --git a/cmd/felis/tui_dashboard.go b/cmd/felis/tui_dashboard.go new file mode 100644 index 0000000..80b06d0 --- /dev/null +++ b/cmd/felis/tui_dashboard.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type dashboardModel struct { + ctx context.Context + store ownerStore + rootDomain string + adminHost string + panelHost string + osUser string + dbURL string + adminExists bool + + focus int + + pgStatus stepStatus + pgDetail string + + mgStatus stepStatus + mgDetail string + + owStatus stepStatus + owDetail string + + paStatus stepStatus + paDetail string + + egStatus stepStatus + egDetail string +} + +func newDashboardModel(ctx context.Context, store ownerStore, dbURL, osUser, rootDomain, adminHost, panelHost string) *dashboardModel { + return &dashboardModel{ + ctx: ctx, + store: store, + dbURL: dbURL, + osUser: osUser, + rootDomain: rootDomain, + adminHost: adminHost, + panelHost: panelHost, + pgStatus: statusPending, + mgStatus: statusPending, + owStatus: statusOptional, + paStatus: statusPending, + egStatus: statusOptional, + } +} + +func (m *dashboardModel) Init() tea.Cmd { return nil } + +func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "up": + if m.focus > 0 { + m.focus-- + } + return m, nil + case "down": + if m.focus < 4 { + m.focus++ + } + return m, nil + case "enter": + return m.enterStep() + case "r", "R": + return m, func() tea.Msg { return switchToDashboard{} } + } + } + return m, nil +} + +func (m *dashboardModel) enterStep() (tea.Model, tea.Cmd) { + switch m.focus { + case 0: + return newPostgresModel(m.dbURL, m.osUser), nil + case 1: + return newMigrationModel(m.dbURL, m.osUser), nil + case 2: + if m.adminExists { + return m, nil + } + return newOwnerModel(m.ctx, m.store, m.osUser, false), nil + case 3: + return m, nil + case 4: + return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil + } + return m, nil +} + +func (m *dashboardModel) View() string { + var b strings.Builder + b.WriteString(tuiHeader("Setup")) + + type step struct { + title string + status stepStatus + detail string + idx int + } + + steps := []step{ + {"Database & Migrations", m.pgStatus, m.pgDetail, 0}, + {"Database Migrations", m.mgStatus, m.mgDetail, 1}, + {"Owner Account", m.owStatus, m.owDetail, 2}, + {"Panel Access", m.paStatus, m.paDetail, 3}, + {"Cloudflare Edge", m.egStatus, m.egDetail, 4}, + } + + for _, s := range steps { + icon := tuiIcon(s.status) + label := s.title + if s.detail != "" { + label += " " + tuiHint.Render(s.detail) + } + prefix := " " + if m.focus == s.idx { + prefix = tuiLabel.Render("▸ ") + } + b.WriteString(fmt.Sprintf("%s%s %s\n\n", prefix, icon, label)) + } + + b.WriteString("\n") + b.WriteString(tuiSeparator()) + b.WriteString("\n") + b.WriteString(tuiAction("enter", "configure", "r", "refresh", "↑↓", "navigate", "esc", "exit")) + return b.String() +} diff --git a/cmd/felis/tui_edge.go b/cmd/felis/tui_edge.go new file mode 100644 index 0000000..5ed48f4 --- /dev/null +++ b/cmd/felis/tui_edge.go @@ -0,0 +1,575 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + + "felis.lolicon.best/internal/cfsetup" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type egStep int + +const ( + egIntro egStep = iota + egAuth + egConfig + egWorking + egDone + egError +) + +type egAuthDoneMsg struct { + token string + account string + err error +} + +type egLoginDoneMsg struct{ err error } + +type egInstallDoneMsg struct{ err error } + +type egSetupDoneMsg struct { + result *cfsetup.Result + err error +} + +type edgeModel struct { + step egStep + + rootDomain string + adminHostname string + panelHostname string + + // cloudflared detection + cloudflaredPath string + certExists bool + installing bool + loginNote string + + // auth step inputs + authInputs []textinput.Model + authFocus int + authErr string + authToken string + authAccount string + + // config step inputs + cfgInputs []textinput.Model + cfgFocus int + cfgErr string + + // working / result + working string + lastErr error + prog []string + result *cfsetup.Result + panelSet string + adminSet string +} + +func newEdgeModel(rootDomain, adminHost, panelHost string) *edgeModel { + m := &edgeModel{ + step: egIntro, + rootDomain: rootDomain, + adminHostname: adminHost, + panelHostname: panelHost, + } + m.detectCloudflared() + return m +} + +func (m *edgeModel) detectCloudflared() { + pre := cfsetup.DetectPreconditions("") + m.cloudflaredPath = pre.CloudflaredPath + m.certExists = pre.CertExists +} + +func (m *edgeModel) Init() tea.Cmd { return nil } + +func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case egLoginDoneMsg: + m.detectCloudflared() + if msg.err != nil && !m.certExists { + m.loginNote = "cloudflared login did not complete: " + msg.err.Error() + } else if !m.certExists { + m.loginNote = "login finished but cert not found — try again" + } else { + m.loginNote = "" + } + return m, nil + + case egInstallDoneMsg: + m.installing = false + if msg.err != nil { + m.loginNote = "install failed: " + msg.err.Error() + } else { + m.detectCloudflared() + m.loginNote = "" + } + return m, nil + + case egAuthDoneMsg: + if msg.err != nil { + m.authErr = msg.err.Error() + return m, nil + } + m.authToken = msg.token + m.authAccount = msg.account + m.step = egConfig + return m, m.enterConfig() + + case egSetupDoneMsg: + m.working = "" + if msg.err != nil { + m.step = egError + m.lastErr = msg.err + return m, nil + } + m.step = egDone + m.result = msg.result + if msg.result != nil { + m.prog = msg.result.Progress + } + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *edgeModel) View() string { + var b strings.Builder + b.WriteString(tuiHeader("Cloudflare Edge")) + + switch m.step { + case egIntro: + b.WriteString(tuiHint.Render("Publish web faces securely via Cloudflare Tunnel + Access.") + "\n\n") + b.WriteString(tuiLabel.Render("Status") + "\n") + if m.cloudflaredPath == "" { + b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n") + } else { + b.WriteString(" " + tuiOK.Render("✓ cloudflared") + " " + tuiHint.Render(m.cloudflaredPath) + "\n") + if m.certExists { + b.WriteString(" " + tuiOK.Render("✓ logged in") + "\n\n") + } else { + b.WriteString(" " + tuiWarn.Render("⟳ not logged in") + " — press l for browser login\n\n") + } + } + if m.loginNote != "" { + b.WriteString(tuiHint.Render(m.loginNote) + "\n\n") + } + if panel := defaultPanelHostname(m.rootDomain, m.panelHostname); panel != "" { + b.WriteString(tuiHint.Render("Player console: "+panel) + "\n") + } + if admin := defaultAdminHostname(m.rootDomain, m.adminHostname); admin != "" { + b.WriteString(tuiHint.Render("Admin console: "+admin) + " (Access-guarded)\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + switch { + case m.cloudflaredPath != "" && m.certExists: + b.WriteString(tuiAction("enter", "continue", "esc", "back")) + case m.cloudflaredPath == "": + b.WriteString(tuiAction("i", "install cloudflared", "esc", "back")) + default: + b.WriteString(tuiAction("l", "login", "esc", "back")) + } + + case egAuth: + b.WriteString(tuiHint.Render("Step 1/2: Enter your Cloudflare credentials.") + "\n\n") + b.WriteString(tuiWizardCard("API Token & Account ID", + "Create a Bearer token at: "+cloudflareAccessTokenTemplateURL, + tuiFormField("API token", m.authInputs[0])+"\n\n"+ + tuiFormField("Account ID", m.authInputs[1]))) + b.WriteString("\n" + tuiInfo("Account ID is in the Cloudflare Dashboard URL: dash.cloudflare.com/") + "\n") + if m.authErr != "" { + b.WriteString("\n" + tuiErrorBanner(m.authErr) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("tab/↑↓", "move", "enter", "continue", "esc", "back")) + + case egConfig: + b.WriteString(tuiHint.Render("Step 2/2: Choose hostnames and who gets access.") + "\n\n") + labels := []string{"Admit (your email, or @your-domain)", "Player console hostname", "Admin console hostname", "Tunnel name", "Config path"} + var fields string + for i, lbl := range labels { + if i > 0 { + fields += "\n\n" + } + fields += tuiFormField(lbl, m.cfgInputs[i]) + } + b.WriteString(tuiWizardCard("Hostnames & Identity", "", fields)) + if m.cfgErr != "" { + b.WriteString("\n" + tuiErrorBanner(m.cfgErr) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("tab/↑↓", "move", "enter", "configure", "esc", "back")) + + case egWorking: + b.WriteString(tuiHint.Render("Configuring Cloudflare Tunnel + Access edge…") + "\n\n") + for _, s := range m.prog { + b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n") + } + if m.working != "" { + b.WriteString(" " + tuiIconSpin + " " + m.working + "\n") + } + if len(m.prog) == 0 && m.working == "" { + b.WriteString(tuiHint.Render("Starting…") + "\n") + } + + case egDone: + b.WriteString(tuiSuccessBanner("Cloudflare edge configured.") + "\n\n") + var box string + if m.result != nil { + box = tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n" + if len(m.result.RoutedHostnames) > 0 { + box += tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n" + } + if m.result.ConfigPath != "" { + box += tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n" + } + } + b.WriteString(tuiCardStyle.Render(box) + "\n\n") + b.WriteString(tuiOK.Render("✓") + " Felis config, Kubernetes Secret, API rollout and cloudflared service updated.\n") + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter/esc", "back")) + + case egError: + b.WriteString(tuiErrorBanner("Edge setup failed.") + "\n\n") + if len(m.prog) > 0 { + b.WriteString(tuiHint.Render("Completed before failure:") + "\n") + for _, s := range m.prog { + b.WriteString(" " + tuiOK.Render("✓") + " " + s + "\n") + } + b.WriteString("\n") + } + if m.lastErr != nil { + b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter", "retry", "esc", "back")) + } + return b.String() +} + +func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.step { + case egIntro: + return m.handleIntroKey(msg) + case egAuth: + return m.handleAuthKey(msg) + case egConfig: + return m.handleCfgKey(msg) + case egDone, egError: + switch msg.String() { + case "ctrl+c", "esc": + if m.step == egDone { + return m, m.sendEdgeResult() + } + return m, func() tea.Msg { return switchToDashboard{} } + case "enter": + if m.step == egDone { + return m, m.sendEdgeResult() + } + if m.step == egError { + m.step = egConfig + m.lastErr = nil + m.prog = nil + return m, nil + } + return m, nil + } + default: + } + return m, nil +} + +func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + return m, func() tea.Msg { return switchToDashboard{} } + case "i", "I": + if m.cloudflaredPath == "" { + m.installing = true + return m, m.installCloudflared() + } + case "l", "L": + if m.cloudflaredPath != "" && !m.certExists { + nm, cmd := m.startLogin() + return nm, cmd + } + case "enter": + if m.cloudflaredPath != "" && m.certExists { + m.step = egAuth + return m, m.enterAuth() + } + } + return m, nil +} + +func (m *edgeModel) handleAuthKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + m.step = egIntro + m.authErr = "" + return m, nil + case "tab", "down": + m.authFocus = (m.authFocus + 1) % 2 + return m, m.focusAuthInput(m.authFocus) + case "shift+tab", "up": + m.authFocus = (m.authFocus + 1) % 2 + return m, m.focusAuthInput(m.authFocus) + case "enter": + return m.submitAuth() + } + return m, m.updateAuthInputs(msg) +} + +func (m *edgeModel) handleCfgKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + m.step = egAuth + m.cfgErr = "" + return m, nil + case "tab", "down": + m.cfgFocus = (m.cfgFocus + 1) % 5 + return m, m.focusCfgInput(m.cfgFocus) + case "shift+tab", "up": + m.cfgFocus = (m.cfgFocus + 4) % 5 + return m, m.focusCfgInput(m.cfgFocus) + case "enter": + return m.submitConfig() + } + return m, m.updateCfgInputs(msg) +} + +func (m *edgeModel) enterAuth() tea.Cmd { + token := tuiInput("cfat_…", 200, true) + account := tuiInput("32-char account ID", 64, false) + m.authInputs = []textinput.Model{token, account} + m.authFocus = 0 + return m.focusAuthInput(0) +} + +func (m *edgeModel) focusAuthInput(i int) tea.Cmd { + var cmd tea.Cmd + for j := range m.authInputs { + if j == i { + cmd = m.authInputs[j].Focus() + } else { + m.authInputs[j].Blur() + } + } + return cmd +} + +func (m *edgeModel) updateAuthInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.authInputs)) + for i := range m.authInputs { + m.authInputs[i], cmds[i] = m.authInputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m *edgeModel) submitAuth() (tea.Model, tea.Cmd) { + token := strings.TrimSpace(m.authInputs[0].Value()) + account := strings.TrimSpace(m.authInputs[1].Value()) + if token == "" { + m.authErr = "a Cloudflare API token is required" + return m, nil + } + if account == "" { + m.authErr = "the Cloudflare account ID is required" + return m, nil + } + if !isHex32(account) { + if strings.HasPrefix(account, "cfat_") { + m.authErr = "that looks like an API token — the Account ID is a 32-char hex string" + } else { + m.authErr = "the Account ID must be 32 hex characters" + } + return m, nil + } + m.authErr = "" + return m, func() tea.Msg { return egAuthDoneMsg{token: token, account: account} } +} + +func (m *edgeModel) enterConfig() tea.Cmd { + identity := tuiInput("you@example.com or @your-domain", 254, false) + panelHost := tuiInput(defaultPanelHostname(m.rootDomain, m.panelHostname), 253, false) + panelHost.SetValue(defaultPanelHostname(m.rootDomain, m.panelHostname)) + adminHost := tuiInput(defaultAdminHostname(m.rootDomain, m.adminHostname), 253, false) + adminHost.SetValue(defaultAdminHostname(m.rootDomain, m.adminHostname)) + tunnel := tuiInput(defaultTunnelName, 64, false) + tunnel.SetValue(defaultTunnelName) + cfgPath := tuiInput(defaultTunnelConfigPath, 256, false) + cfgPath.SetValue(defaultTunnelConfigPath) + m.cfgInputs = []textinput.Model{identity, panelHost, adminHost, tunnel, cfgPath} + m.cfgFocus = 0 + return m.focusCfgInput(0) +} + +func (m *edgeModel) focusCfgInput(i int) tea.Cmd { + var cmd tea.Cmd + for j := range m.cfgInputs { + if j == i { + cmd = m.cfgInputs[j].Focus() + } else { + m.cfgInputs[j].Blur() + } + } + return cmd +} + +func (m *edgeModel) updateCfgInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.cfgInputs)) + for i := range m.cfgInputs { + m.cfgInputs[i], cmds[i] = m.cfgInputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) { + identity := strings.TrimSpace(m.cfgInputs[0].Value()) + panelHost := normalizeEdgeHostname(m.cfgInputs[1].Value()) + adminHost := normalizeEdgeHostname(m.cfgInputs[2].Value()) + tunnel := strings.TrimSpace(m.cfgInputs[3].Value()) + cfgPath := strings.TrimSpace(m.cfgInputs[4].Value()) + + if identity == "" { + m.cfgErr = "enter who Access should admit" + m.cfgFocus = 0 + return m, nil + } + if strings.HasPrefix(identity, "@") && strings.TrimPrefix(identity, "@") == "" { + m.cfgErr = "enter a domain after the @, e.g. @your-domain" + m.cfgFocus = 0 + return m, nil + } + if err := validateEdgeHostname("player console", panelHost, false); err != nil { + m.cfgErr = err.Error() + m.cfgFocus = 1 + return m, nil + } + if err := validateEdgeHostname("admin console", adminHost, true); err != nil { + m.cfgErr = err.Error() + m.cfgFocus = 2 + return m, nil + } + if panelHost != "" && strings.EqualFold(panelHost, adminHost) { + m.cfgErr = "player console and admin console hostnames must be different" + m.cfgFocus = 2 + return m, nil + } + if tunnel == "" { + tunnel = defaultTunnelName + } + if cfgPath == "" { + cfgPath = defaultTunnelConfigPath + } + + m.panelSet = panelHost + m.adminSet = adminHost + m.cfgErr = "" + m.step = egWorking + m.working = "Starting…" + + var id cfsetup.AccessIdentity + if strings.HasPrefix(identity, "@") { + id.EmailDomains = []string{strings.TrimPrefix(identity, "@")} + } else { + id.Emails = []string{identity} + } + + runner := &cfsetup.ExecRunner{ + Cloudflared: m.cloudflaredPath, + APIToken: m.authToken, + AccountID: m.authAccount, + } + p := cfsetup.Params{ + PanelHostname: panelHost, + AdminHostname: adminHost, + PanelOrigin: localPanelOrigin(), + TunnelName: tunnel, + ConfigPath: cfgPath, + AccessIdentity: id, + Pre: cfsetup.DetectPreconditions(m.authToken), + } + + return m, m.runEdgeSetup(runner, p) +} + +func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cmd { + var progress []string + p.OnProgress = func(step string) { + progress = append(progress, step) + } + return func() tea.Msg { + result, err := cfsetup.Setup(context.Background(), runner, p) + if err != nil { + return egSetupDoneMsg{err: err} + } + progress = append(progress, "Applying Felis config and starting cloudflared…") + if err := applyCloudflareEdge(context.Background(), result, p.PanelHostname, p.AdminHostname, m.cloudflaredPath); err != nil { + return egSetupDoneMsg{err: err} + } + progress = append(progress, "Updated Felis config and started cloudflared") + result.Progress = progress + return egSetupDoneMsg{result: result} + } +} + +func (m *edgeModel) sendEdgeResult() tea.Cmd { + return func() tea.Msg { + return edgeResultMsg{ + result: m.result, + panelHostname: m.panelSet, + adminHostname: m.adminSet, + } + } +} + +func (m *edgeModel) startLogin() (tea.Model, tea.Cmd) { + c := exec.CommandContext(context.Background(), m.cloudflaredPath, "tunnel", "login") + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return egLoginDoneMsg{err: err} + }) +} + +func (m *edgeModel) installCloudflared() tea.Cmd { + return func() tea.Msg { + arch := "amd64" + if out, err := exec.Command("uname", "-m").Output(); err == nil { + if strings.TrimSpace(string(out)) == "aarch64" { + arch = "arm64" + } + } + url := "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + resp, err := http.Get(url) + if err != nil { + return egInstallDoneMsg{err: fmt.Errorf("download: %w", err)} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return egInstallDoneMsg{err: fmt.Errorf("download returned status %d", resp.StatusCode)} + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, resp.Body); err != nil { + return egInstallDoneMsg{err: fmt.Errorf("read: %w", err)} + } + if err := os.WriteFile("/usr/local/bin/cloudflared", buf.Bytes(), 0o755); err != nil { + return egInstallDoneMsg{err: fmt.Errorf("install: %w", err)} + } + return egInstallDoneMsg{} + } +} diff --git a/cmd/felis/tui_edge_apply.go b/cmd/felis/tui_edge_apply.go new file mode 100644 index 0000000..aba11e9 --- /dev/null +++ b/cmd/felis/tui_edge_apply.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "felis.lolicon.best/internal/cfsetup" + "felis.lolicon.best/internal/config" + + "github.com/BurntSushi/toml" +) + +const podSetupConfigPath = "/etc/felis/felis.pod.toml" +const cloudflaredFelisUnit = "/etc/systemd/system/cloudflared-felis.service" + +func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost, adminHost, cloudflaredBin string) error { + if result == nil || result.AccessAud == "" { + return fmt.Errorf("edge result did not include an Access audience") + } + if adminHost == "" { + return fmt.Errorf("admin hostname is required") + } + for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} { + if err := updateAuthConfig(path, panelHost, adminHost, result.AccessAud); err != nil { + return err + } + } + if err := applyFelisConfigSecret(ctx); err != nil { + return err + } + if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil { + return err + } + if err := installCloudflaredService(ctx, cloudflaredBin, result.ConfigPath); err != nil { + return err + } + return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s") +} + +func updateAuthConfig(path, panelHost, adminHost, aud string) error { + cfg, err := config.Load(path) + if err != nil { + return err + } + if panelHost != "" { + cfg.Auth.PanelHostname = panelHost + } + cfg.Auth.AdminHostname = adminHost + cfg.Auth.AccessJWTAud = aud + return writeConfig(path, cfg) +} + +func writeConfig(path string, cfg *config.Config) error { + tmp, err := os.CreateTemp(filepath.Dir(path), ".felis-*.toml") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := toml.NewEncoder(tmp).Encode(cfg); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +func applyFelisConfigSecret(ctx context.Context) error { + out, err := kubectlOutput(ctx, + "-n", "felis", "create", "secret", "generic", "felis-config", + "--from-file=felis.toml="+podSetupConfigPath, + "--dry-run=client", "-o", "yaml", + ) + if err != nil { + return err + } + return kubectlWithInput(ctx, out, "apply", "-f", "-") +} + +func installCloudflaredService(ctx context.Context, cloudflaredBin, configPath string) error { + if cloudflaredBin == "" { + return fmt.Errorf("cloudflared binary path is empty") + } + if configPath == "" { + return fmt.Errorf("cloudflared config path is empty") + } + unit := fmt.Sprintf(`[Unit] +Description=Felis Cloudflare Tunnel +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=%s --config %s tunnel run +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +`, cloudflaredBin, configPath) + if err := os.WriteFile(cloudflaredFelisUnit, []byte(unit), 0o644); err != nil { + return err + } + if err := systemctl(ctx, "daemon-reload"); err != nil { + return err + } + return systemctl(ctx, "enable", "--now", "cloudflared-felis.service") +} + +func kubectl(ctx context.Context, args ...string) error { + _, err := kubectlOutput(ctx, args...) + return err +} + +func kubectlWithInput(ctx context.Context, input []byte, args ...string) error { + _, err := runK3sKubectl(ctx, input, args...) + return err +} + +func kubectlOutput(ctx context.Context, args ...string) ([]byte, error) { + return runK3sKubectl(ctx, nil, args...) +} + +func runK3sKubectl(ctx context.Context, input []byte, args ...string) ([]byte, error) { + fullArgs := append([]string{"kubectl"}, args...) + cmd := exec.CommandContext(ctx, "k3s", fullArgs...) + cmd.Env = append(os.Environ(), "KUBECONFIG="+hostBootstrapKubeconfigPath) + if input != nil { + cmd.Stdin = bytes.NewReader(input) + } + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("k3s %v: %w: %s", fullArgs, err, string(out)) + } + return out, nil +} + +func systemctl(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "systemctl", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl %v: %w: %s", args, err, string(out)) + } + return nil +} diff --git a/cmd/felis/tui_migration.go b/cmd/felis/tui_migration.go new file mode 100644 index 0000000..c64b753 --- /dev/null +++ b/cmd/felis/tui_migration.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "fmt" + "time" + + "felis.lolicon.best/internal/store" + + tea "github.com/charmbracelet/bubbletea" +) + +type migRunMsg struct { + n int + total int + err error +} + +type migrationModel struct { + dbURL string + osUser string + + state string + applied int + total int + lastErr error +} + +func newMigrationModel(dbURL, osUser string) *migrationModel { + return &migrationModel{ + dbURL: dbURL, + osUser: osUser, + state: "checking", + } +} + +func (m *migrationModel) Init() tea.Cmd { + return func() tea.Msg { + n, err := countMigrations(m.dbURL) + if err != nil { + return migRunMsg{err: err} + } + total, err := totalMigrations() + return migRunMsg{n: n, total: total, err: err} + } +} + +func (m *migrationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case migRunMsg: + if msg.err != nil { + m.state = "error" + m.lastErr = msg.err + return m, nil + } + m.applied = msg.n + m.total = msg.total + if m.applied < m.total { + m.state = "pending" + return m, nil + } + return m, func() tea.Msg { return migrationDoneMsg{n: msg.n} } + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, func() tea.Msg { return switchToDashboard{} } + case "enter": + if m.state == "pending" { + m.state = "running" + return m, runMigrationsCmd(m.dbURL) + } + } + } + return m, nil +} + +func (m *migrationModel) View() string { + var b string + b += tuiHeader("Database Migrations") + "\n" + + switch m.state { + case "checking": + b += tuiHint.Render("Checking migration status…") + "\n" + case "pending": + b += tuiWarn.Render(fmt.Sprintf("%d of %d migrations applied. %d pending.", m.applied, m.total, m.total-m.applied)) + "\n\n" + b += tuiInfo("Press enter to run pending migrations.") + "\n" + case "running": + b += tuiHint.Render("Running migrations…") + "\n" + b += tuiInfo("This should take only a moment.") + "\n" + case "error": + b += tuiErr.Render("Migration check failed:") + "\n" + b += tuiHint.Render(m.lastErr.Error()) + "\n" + default: + b += tuiOK.Render(fmt.Sprintf("✓ %d migrations applied.", m.applied)) + "\n" + } + + b += "\n" + b += tuiSeparator() + "\n" + switch m.state { + case "pending": + b += tuiAction("enter", "run", "esc", "back") + case "running": + b += tuiAction("esc", "cancel") + default: + b += tuiAction("esc", "back") + } + return b +} + +func runMigrationsCmd(dbURL string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + drv, err := store.Open(ctx, dbURL) + if err != nil { + return migRunMsg{err: fmt.Errorf("open db: %w", err)} + } + defer drv.Close() + migrations, err := store.LoadMigrations() + if err != nil { + return migRunMsg{err: err} + } + _, err = store.Up(ctx, drv, migrations) + if err != nil { + return migRunMsg{err: err} + } + applied, err := drv.AppliedVersions(ctx) + if err != nil { + return migRunMsg{err: err} + } + return migRunMsg{n: len(applied), total: len(migrations)} + } +} + +func totalMigrations() (int, error) { + migrations, err := store.LoadMigrations() + if err != nil { + return 0, err + } + return len(migrations), nil +} diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go new file mode 100644 index 0000000..c2b573b --- /dev/null +++ b/cmd/felis/tui_owner.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type owAuthMsg struct { + matched string + ok bool + err error +} + +type owProvisionMsg struct { + outcome breakGlassOutcome + err error +} + +type owStep int + +const ( + owAuth owStep = iota + owOverride + owProvision + owWorking + owDone + owError +) + +type ownerModel struct { + ctx context.Context + store ownerStore + osUser string + adminExists bool + mode string // "bootstrap", "recovery", "root_override" + accountable string + + step owStep + inputs []textinput.Model + focus int + formErr string + working string + attempt string + + username string + displayPassword string + auditWarning string +} + +func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel { + m := &ownerModel{ + ctx: ctx, + store: store, + osUser: osUser, + adminExists: adminExists, + } + if adminExists { + m.step = owAuth + } else { + m.mode = "bootstrap" + m.accountable = osUser + m.step = owProvision + } + return m +} + +func (m *ownerModel) Init() tea.Cmd { + if m.step == owAuth { + return m.buildAuth() + } + return m.buildProvision(true) +} + +func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case owAuthMsg: + m.working = "" + if msg.err != nil { + return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } + } + if msg.ok { + m.mode = "recovery" + m.accountable = msg.matched + m.step = owProvision + return m, m.buildProvision(false) + } + m.step = owOverride + m.formErr = "" + return m, m.buildOverride() + + case owProvisionMsg: + if msg.err != nil { + return m, func() tea.Msg { return ownerResultMsg{err: msg.err} } + } + m.step = owDone + m.displayPassword = msg.outcome.displayPassword + if msg.outcome.auditErr != nil { + m.auditWarning = msg.outcome.auditErr.Error() + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case owDone, owError: + switch msg.String() { + case "ctrl+c", "esc", "enter": + if m.step == owDone { + return m, m.ownerResultCmd() + } + return m, nil + } + return m, nil + case owWorking: + return m, nil + default: + return m.handleFormKey(msg) + } + } + // Forward to inputs. + if m.step != owWorking && m.step != owDone && m.step != owError { + return m, m.updateInputs(msg) + } + return m, nil +} + +func (m *ownerModel) View() string { + var b strings.Builder + b.WriteString(tuiHeader("Owner Account")) + + switch m.step { + case owAuth: + b.WriteString(tuiHint.Render("A staff account exists. Identify yourself before proceeding.") + "\n\n") + b.WriteString(tuiWizardCard("Admin Authentication", "", + tuiFormField("Admin username", m.inputs[0])+"\n\n"+ + tuiFormField("Admin password", m.inputs[1]))) + if m.formErr != "" { + b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("tab/↑↓", "move", "enter", "verify", "esc", "cancel")) + + case owOverride: + b.WriteString(tuiErrorBanner("That credential did not match.") + "\n\n") + b.WriteString(tuiHint.Render(fmt.Sprintf("You can proceed as OS user %q with root authority.", m.osUser)) + "\n\n") + b.WriteString(tuiWizardCard("Root Override", "", + tuiFormField("Type "+breakGlassOverrideToken+" to confirm", m.inputs[0]))) + if m.formErr != "" { + b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("enter", "confirm", "esc", "go back")) + + case owProvision: + if m.mode == "bootstrap" { + b.WriteString(tuiHint.Render(fmt.Sprintf("Creating the first Owner. Recorded as OS user %q.", m.osUser)) + "\n\n") + } else if m.mode == "root_override" { + b.WriteString(tuiWarn.Render("Root override — a one-time password will be generated.") + "\n\n") + } else { + b.WriteString(tuiHint.Render(fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)) + "\n\n") + } + var fields string + fields = tuiFormField("Owner username", m.inputs[0]) + "\n\n" + fields += tuiFormField("Owner email (optional)", m.inputs[1]) + if m.mode == "bootstrap" { + fields += "\n\n" + tuiFormField("Owner password", m.inputs[2]) + fields += "\n\n" + tuiFormField("Confirm password", m.inputs[3]) + } + b.WriteString(tuiWizardCard("Account Details", "", fields)) + if m.formErr != "" { + b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n") + } + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("tab/↑↓", "move", "enter", "provision", "esc", "cancel")) + + case owWorking: + msg := m.working + if msg == "" { + msg = "Working…" + } + b.WriteString(tuiHint.Render(msg) + "\n") + + case owDone: + b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n") + var box strings.Builder + box.WriteString(tuiLabel.Render("username ") + m.username + "\n") + if m.displayPassword != "" { + box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n") + box.WriteString(tuiWarn.Render("Record this password — it is shown only once.") + "\n") + } else { + box.WriteString(tuiHint.Render("Log in with the password you entered.") + "\n") + } + if m.auditWarning != "" { + box.WriteString("\n" + tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n") + } + b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") + b.WriteString(tuiSeparator() + "\n") + b.WriteString(tuiAction("enter/esc", "back")) + + case owError: + b.WriteString(tuiErrorBanner("Owner provisioning failed.") + "\n") + b.WriteString("\n" + tuiSeparator() + "\n") + b.WriteString(tuiAction("esc", "exit")) + } + return b.String() +} + +func (m *ownerModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "esc": + if m.step == owOverride { + m.step, m.formErr = owAuth, "" + return m, m.buildAuth() + } + return m, func() tea.Msg { return switchToDashboard{} } + case "tab", "down": + m.focus = m.focus + 1 + if m.focus >= len(m.inputs) { + m.focus = 0 + } + return m, m.focusInput(m.focus) + case "shift+tab", "up": + m.focus = m.focus - 1 + if m.focus < 0 { + m.focus = len(m.inputs) - 1 + } + return m, m.focusInput(m.focus) + case "enter": + return m.submit() + } + return m, m.updateInputs(msg) +} + +func (m *ownerModel) focusInput(i int) tea.Cmd { + var cmd tea.Cmd + for j := range m.inputs { + if j == i { + cmd = m.inputs[j].Focus() + } else { + m.inputs[j].Blur() + } + } + return cmd +} + +func (m *ownerModel) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + return tea.Batch(cmds...) +} + +func (m *ownerModel) ownerResultCmd() tea.Cmd { + return func() tea.Msg { + return ownerResultMsg{ + username: m.username, + displayPassword: m.displayPassword, + mode: m.mode, + accountable: m.accountable, + auditWarning: m.auditWarning, + } + } +} + +func (m *ownerModel) setInputs(ins []textinput.Model) tea.Cmd { + m.inputs = ins + m.focus = 0 + return m.focusInput(0) +} + +func (m *ownerModel) buildAuth() tea.Cmd { + user := tuiInput("admin username", 64, false) + pass := tuiInput("admin password", 128, true) + return m.setInputs([]textinput.Model{user, pass}) +} + +func (m *ownerModel) buildOverride() tea.Cmd { + confirm := tuiInput("type "+breakGlassOverrideToken, 16, false) + return m.setInputs([]textinput.Model{confirm}) +} + +func (m *ownerModel) buildProvision(withPassword bool) tea.Cmd { + user := tuiInput("owner", 64, false) + user.SetValue("owner") + email := tuiInput("(optional)", 254, false) + ins := []textinput.Model{user, email} + if withPassword { + ins = append(ins, tuiInput("at least 8 characters", 128, true)) + ins = append(ins, tuiInput("re-enter password", 128, true)) + } + return m.setInputs(ins) +} + +func (m *ownerModel) submit() (tea.Model, tea.Cmd) { + switch m.step { + case owAuth: + return m.submitAuth() + case owOverride: + return m.submitOverride() + case owProvision: + return m.submitProvision() + } + return m, nil +} + +func (m *ownerModel) submitAuth() (tea.Model, tea.Cmd) { + user := strings.TrimSpace(m.inputs[0].Value()) + pass := m.inputs[1].Value() + if user == "" || pass == "" { + m.formErr = "enter the username and password of an existing admin" + return m, nil + } + m.attempt = user + m.formErr, m.working = "", "Verifying admin credential…" + m.step = owWorking + return m, func() tea.Msg { + matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass) + return owAuthMsg{matched: matched, ok: ok, err: err} + } +} + +func (m *ownerModel) submitOverride() (tea.Model, tea.Cmd) { + if m.inputs[0].Value() != breakGlassOverrideToken { + m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed" + return m, nil + } + m.mode = "root_override" + m.accountable = m.osUser + m.step = owProvision + return m, m.buildProvision(false) +} + +func (m *ownerModel) submitProvision() (tea.Model, tea.Cmd) { + owner := strings.TrimSpace(m.inputs[0].Value()) + if owner == "" { + m.formErr = "owner username is required" + return m, m.focusForField(0) + } + email := m.inputs[1].Value() + password := "" + if m.mode == "bootstrap" { + pw := m.inputs[2].Value() + confirm := m.inputs[3].Value() + if err := validateOwnerPassword(pw); err != nil { + m.formErr = err.Error() + return m, m.focusForField(2) + } + if pw != confirm { + m.formErr = "the two passwords do not match" + return m, m.focusForField(3) + } + password = pw + } + m.username = owner + m.formErr, m.working = "", "Provisioning Owner account…" + m.step = owWorking + return m, func() tea.Msg { + out, err := performBreakGlass(m.ctx, m.store, breakGlassOp{ + mode: m.mode, + accountable: m.accountable, + osUser: m.osUser, + ownerUsername: owner, + ownerEmail: email, + ownerPassword: password, + attemptedAdmin: m.attempt, + }) + return owProvisionMsg{outcome: out, err: err} + } +} + +func (m *ownerModel) focusForField(i int) tea.Cmd { + m.focus = i + return m.focusInput(i) +} diff --git a/cmd/felis/tui_postgres.go b/cmd/felis/tui_postgres.go new file mode 100644 index 0000000..cfc370e --- /dev/null +++ b/cmd/felis/tui_postgres.go @@ -0,0 +1,260 @@ +package main + +import ( + "context" + "fmt" + "net" + "os/exec" + "strings" + "time" + + "felis.lolicon.best/internal/store" + + tea "github.com/charmbracelet/bubbletea" +) + +type pgCheckMsg struct { + running bool + reachable bool + err error +} + +type pgInstallMsg struct{ err error } + +type pgCreateMsg struct{ err error } + +type postgresModel struct { + dbURL string + osUser string + + state string // "checking", "missing", "installing", "creating", "done", "error" + lastErr error + pgExists bool +} + +func newPostgresModel(dbURL, osUser string) *postgresModel { + return &postgresModel{ + dbURL: dbURL, + osUser: osUser, + state: "checking", + } +} + +func (m *postgresModel) Init() tea.Cmd { + return checkPostgresCmd(m.dbURL) +} + +func (m *postgresModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case pgCheckMsg: + if msg.err != nil || !msg.reachable { + m.state = "missing" + m.lastErr = msg.err + return m, nil + } + return m, func() tea.Msg { return pgDoneMsg{} } + + case pgInstallMsg: + if msg.err != nil { + m.state = "error" + m.lastErr = msg.err + return m, nil + } + m.state = "creating" + return m, createDatabaseCmd(m.dbURL) + + case pgCreateMsg: + if msg.err != nil { + m.state = "error" + m.lastErr = msg.err + return m, nil + } + return m, func() tea.Msg { return pgDoneMsg{} } + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, func() tea.Msg { return switchToDashboard{} } + case "i", "I": + if m.state == "missing" { + m.state = "installing" + return m, installPostgresCmd() + } + } + } + return m, nil +} + +func (m *postgresModel) View() string { + var b strings.Builder + b.WriteString(tuiHeader("Database Setup")) + + switch m.state { + case "checking": + b.WriteString(tuiHint.Render("Checking PostgreSQL status…") + "\n") + case "missing": + b.WriteString(tuiWarn.Render("PostgreSQL is not reachable.") + "\n\n") + if m.lastErr != nil { + b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n\n") + } + b.WriteString(tuiInfo("Press i to install PostgreSQL, or esc to skip.") + "\n") + case "installing": + b.WriteString(tuiHint.Render("Installing PostgreSQL via apt…") + "\n") + b.WriteString(tuiInfo("This may take up to a minute.") + "\n") + case "creating": + b.WriteString(tuiHint.Render("PostgreSQL installed. Creating database…") + "\n") + case "done": + b.WriteString(tuiOK.Render("✓ Database is ready.") + "\n") + case "error": + b.WriteString(tuiErr.Render("Failed to set up PostgreSQL:") + "\n") + if m.lastErr != nil { + b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n") + } + } + + b.WriteString("\n") + b.WriteString(tuiSeparator()) + b.WriteString("\n") + switch m.state { + case "missing": + b.WriteString(tuiAction("i", "install", "esc", "back")) + case "done", "error": + b.WriteString(tuiAction("esc", "back")) + default: + b.WriteString(tuiAction("esc", "cancel")) + } + return b.String() +} + +func checkPostgresCmd(dbURL string) tea.Cmd { + return func() tea.Msg { + err := checkPostgres(dbURL) + if err != nil { + return pgCheckMsg{reachable: false, err: err} + } + return pgCheckMsg{reachable: true} + } +} + +func checkPostgres(dbURL string) error { + cfg, err := parseDBURL(dbURL) + if err != nil { + return fmt.Errorf("invalid database URL: %w", err) + } + conn, err := net.DialTimeout("tcp", cfg.addr, 2*time.Second) + if err != nil { + return fmt.Errorf("cannot reach PostgreSQL at %s: %w", cfg.addr, err) + } + conn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + drv, err := store.Open(ctx, dbURL) + if err != nil { + return fmt.Errorf("connect to PostgreSQL: %w", err) + } + drv.Close() + return nil +} + +func installPostgresCmd() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "apt-get", "install", "-y", "postgresql") + out, err := cmd.CombinedOutput() + if err != nil { + return pgInstallMsg{err: fmt.Errorf("%w: %s", err, string(out))} + } + // Start the service. + start := exec.CommandContext(ctx, "systemctl", "restart", "postgresql") + start.CombinedOutput() + return pgInstallMsg{} + } +} + +func createDatabaseCmd(dbURL string) tea.Cmd { + return func() tea.Msg { + cfg, err := parseDBURL(dbURL) + if err != nil { + return pgCreateMsg{err: err} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Create user and database via PostgreSQL command line. + cmds := [][]string{ + {"psql", "-c", fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s';", cfg.user, cfg.pass)}, + {"psql", "-c", fmt.Sprintf("CREATE DATABASE %s OWNER %s;", cfg.db, cfg.user)}, + {"psql", "-c", fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s;", cfg.db, cfg.user)}, + } + for _, args := range cmds { + cmd := exec.CommandContext(ctx, "su", append([]string{"-", "postgres", "-c"}, strings.Join(args, " "))...) + out, err := cmd.CombinedOutput() + if err != nil { + // Ignore "already exists" errors. + s := string(out) + if strings.Contains(s, "already exists") { + continue + } + return pgCreateMsg{err: fmt.Errorf("%w: %s", err, s)} + } + } + return pgCreateMsg{} + } +} + +type dbCfg struct { + addr string + user string + pass string + db string +} + +func parseDBURL(url string) (dbCfg, error) { + // Simple parser for postgres://user:pass@host:port/db?options + s := strings.TrimPrefix(url, "postgres://") + s = strings.TrimPrefix(s, "postgresql://") + parts := strings.SplitN(s, "@", 2) + if len(parts) != 2 { + return dbCfg{}, fmt.Errorf("malformed URL") + } + auth := strings.SplitN(parts[0], ":", 2) + rest := strings.SplitN(parts[1], "/", 2) + if len(rest) < 2 { + return dbCfg{}, fmt.Errorf("malformed URL: no database") + } + hostport := rest[0] + dbname := strings.SplitN(rest[1], "?", 2)[0] + if !strings.Contains(hostport, ":") { + hostport += ":5432" + } + return dbCfg{ + addr: hostport, + user: auth[0], + pass: func() string { + if len(auth) > 1 { + return auth[1] + } + return "" + }(), + db: dbname, + }, nil +} + +func countMigrations(dbURL string) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + drv, err := store.Open(ctx, dbURL) + if err != nil { + return 0, err + } + defer drv.Close() + if err := drv.EnsureVersionTable(ctx); err != nil { + return 0, err + } + applied, err := drv.AppliedVersions(ctx) + if err != nil { + return 0, err + } + return len(applied), nil +} diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go new file mode 100644 index 0000000..d8615b7 --- /dev/null +++ b/cmd/felis/tui_root.go @@ -0,0 +1,265 @@ +package main + +import ( + "context" + "fmt" + + "felis.lolicon.best/internal/cfsetup" + + tea "github.com/charmbracelet/bubbletea" +) + +// ---- Messages: sub-model → root ---- + +type pgDoneMsg struct{ err error } + +type migrationDoneMsg struct { + n int + err error +} + +type ownerResultMsg struct { + username string + displayPassword string + mode string + accountable string + auditWarning string + err error +} + +type edgeResultMsg struct { + result *cfsetup.Result + panelHostname string + adminHostname string + err error +} + +type panelCheckMsg struct{ result panelAccessResult } + +// switchToDashboard tells the root to show the dashboard. +type switchToDashboard struct{} + +// ---- rootModel: top-level session ---- + +type rootModel struct { + ctx context.Context + + screen tea.Model // current active screen + dashboard *dashboardModel // always preserved + + result breakGlassResult + err error + + mode consoleMode + dbURL string + store ownerStore + osUser string + rootDomain string + adminHost string + panelHost string + adminExists bool +} + +func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *rootModel { + rm := &rootModel{ + ctx: ctx, + dbURL: dbURL, + store: store, + osUser: osUser, + rootDomain: rootDomain, + adminHost: adminHostname, + panelHost: panelHostname, + adminExists: adminExists, + mode: mode, + dashboard: newDashboardModel(ctx, store, dbURL, osUser, rootDomain, adminHostname, panelHostname), + result: breakGlassResult{ + osUser: osUser, + rootDomain: rootDomain, + adminHostname: adminHostname, + }, + } + rm.dashboard.adminExists = adminExists + rm.refreshDashboard() + if mode == consoleModeBreakGlass { + rm.screen = newOwnerModel(ctx, store, osUser, adminExists) + } else { + rm.screen = rm.dashboard + } + return rm +} + +func (m *rootModel) Init() tea.Cmd { + if m.mode == consoleModeSetup { + return m.checkStatus() + } + return m.screen.Init() +} + +func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case pgDoneMsg: + if msg.err != nil { + m.dashboard.pgStatus = statusFailed + m.dashboard.pgDetail = msg.err.Error() + m.showDashboard() + return m, nil + } + m.dashboard.pgStatus = statusDone + m.dashboard.pgDetail = "ready" + m.showDashboard() + return m, m.checkMigrations() + + case migrationDoneMsg: + if msg.err == nil { + m.dashboard.mgStatus = statusDone + m.dashboard.mgDetail = fmt.Sprintf("%d applied", msg.n) + } else { + m.dashboard.mgStatus = statusFailed + m.dashboard.mgDetail = msg.err.Error() + } + m.showDashboard() + if msg.err != nil { + return m, nil + } + return m, m.checkPanel() + + case ownerResultMsg: + if msg.err != nil { + if m.mode == consoleModeBreakGlass { + m.err = msg.err + return m, tea.Quit + } + m.dashboard.owStatus = statusFailed + m.dashboard.owDetail = msg.err.Error() + m.showDashboard() + return m, nil + } + m.result.provisioned = true + m.result.username = msg.username + m.result.displayPassword = msg.displayPassword + m.result.mode = msg.mode + m.result.accountable = msg.accountable + m.result.auditWarning = msg.auditWarning + if m.mode == consoleModeBreakGlass { + return m, tea.Quit + } + m.adminExists = true + m.dashboard.adminExists = true + m.dashboard.owStatus = statusDone + m.dashboard.owDetail = msg.username + m.showDashboard() + return m, m.checkPanel() + + case panelCheckMsg: + m.result.panelURL = msg.result.url + if msg.result.err != nil { + m.dashboard.paStatus = statusFailed + m.dashboard.paDetail = msg.result.err.Error() + } else { + m.dashboard.paStatus = statusDone + m.dashboard.paDetail = msg.result.url + } + m.showDashboard() + return m, nil + + case edgeResultMsg: + if msg.err != nil { + if m.mode == consoleModeBreakGlass { + m.err = msg.err + return m, tea.Quit + } + m.dashboard.egStatus = statusFailed + m.dashboard.egDetail = msg.err.Error() + m.showDashboard() + return m, nil + } + m.result.edgeConfigured = true + m.result.edgeAud = msg.result.AccessAud + m.result.edgeRoutedHosts = msg.result.RoutedHostnames + m.result.edgeConfigPath = msg.result.ConfigPath + m.result.edgePanelHostname = msg.panelHostname + m.result.edgeAdminHostname = msg.adminHostname + if m.mode == consoleModeBreakGlass { + return m, tea.Quit + } + m.dashboard.egStatus = statusDone + m.dashboard.egDetail = "configured" + m.showDashboard() + return m, nil + + case switchToDashboard: + m.refreshDashboard() + return m, m.checkStatus() + + } + + if m.screen != nil { + newScreen, cmd := m.screen.Update(msg) + if newScreen != nil { + if newScreen != m.screen { + m.screen = newScreen + return m, tea.Batch(cmd, m.screen.Init()) + } + } + return m, cmd + } + return m, nil +} + +func (m *rootModel) View() string { + if m.screen != nil { + return m.screen.View() + } + return "" +} + +func (m *rootModel) showDashboard() { + m.screen = m.dashboard +} + +func (m *rootModel) refreshDashboard() { + d := m.dashboard + d.pgStatus = statusPending + d.mgStatus = statusPending + d.owStatus = statusOptional + d.paStatus = statusPending + d.egStatus = statusOptional + if m.adminExists { + d.owStatus = statusDone + d.owDetail = "already exists (use breakGlass to reset)" + } + if m.result.provisioned { + d.owStatus = statusDone + d.owDetail = m.result.username + } + if m.result.edgeConfigured { + d.egStatus = statusDone + d.egDetail = "configured" + } + if m.result.panelURL != "" { + d.paStatus = statusDone + d.paDetail = m.result.panelURL + } +} + +func (m *rootModel) checkStatus() tea.Cmd { + return func() tea.Msg { + if err := checkPostgres(m.dbURL); err != nil { + return pgDoneMsg{err: err} + } + return pgDoneMsg{} + } +} + +func (m *rootModel) checkMigrations() tea.Cmd { + return func() tea.Msg { + n, err := countMigrations(m.dbURL) + return migrationDoneMsg{n: n, err: err} + } +} + +func (m *rootModel) checkPanel() tea.Cmd { + return func() tea.Msg { + return panelCheckMsg{result: checkPanelAccess(m.rootDomain)} + } +} diff --git a/cmd/felis/tui_styles.go b/cmd/felis/tui_styles.go new file mode 100644 index 0000000..7e7ce7e --- /dev/null +++ b/cmd/felis/tui_styles.go @@ -0,0 +1,111 @@ +package main + +import "github.com/charmbracelet/lipgloss" + +var ( + // Color palette — semantic, terminal-safe + cPrimary = lipgloss.Color("39") // bright cyan-blue + cSuccess = lipgloss.Color("42") // green + cWarning = lipgloss.Color("214") // orange + cError = lipgloss.Color("196") // red + cDim = lipgloss.Color("240") // gray + cAccent = lipgloss.Color("99") // purple + cBgDark = lipgloss.Color("236") // dark gray background + cBgInput = lipgloss.Color("235") // input field bg + cWhite = lipgloss.Color("15") + + // Title bar — inverted primary + tuiTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(cWhite). + Background(cPrimary). + Padding(0, 2). + Width(70) + + // Section header + tuiSection = lipgloss.NewStyle(). + Bold(true). + Foreground(cPrimary). + Padding(0, 1) + + // Card styles + tuiCardStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cDim). + Padding(1, 2) + + tuiCardFocusedStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(cPrimary). + Padding(1, 2) + + // Form label + tuiLabel = lipgloss.NewStyle(). + Bold(true). + Foreground(cPrimary) + + // Hint / help text + tuiHint = lipgloss.NewStyle(). + Foreground(cWhite) + + // Success text + tuiOK = lipgloss.NewStyle(). + Bold(true). + Foreground(cSuccess) + + // Warning text + tuiWarn = lipgloss.NewStyle(). + Bold(true). + Foreground(cWarning) + + // Error text + tuiErr = lipgloss.NewStyle(). + Bold(true). + Foreground(cError) + + // Password display — inverted highlight + tuiPassword = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("0")). + Background(cWarning). + Padding(0, 1) + + // Action bar — bottom stripe + tuiActionBar = lipgloss.NewStyle(). + Foreground(cWhite). + Padding(0, 1) + + // Status icon styles + tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓") + tuiIconInPro = lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("→") + tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○") + tuiIconErr = lipgloss.NewStyle().Bold(true).Foreground(cError).Render("✗") + tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳") + + // Step card dimensions + tuiStepWidth = 60 + tuiStepHeight = 5 + + // Info box — subtle background + tuiInfoBox = lipgloss.NewStyle(). + Background(cBgDark). + Padding(1, 2). + Width(60) +) + +func tuiIcon(status stepStatus) string { + switch status { + case statusDone: + return tuiIconOK + case statusPending: + return tuiIconInPro + case statusOptional: + return tuiIconOpt + case statusFailed: + return tuiIconErr + case statusRunning: + return tuiIconSpin + default: + return " " + } +} diff --git a/cmd/felis/tui_widgets.go b/cmd/felis/tui_widgets.go new file mode 100644 index 0000000..ddd5769 --- /dev/null +++ b/cmd/felis/tui_widgets.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" +) + +type stepStatus int + +const ( + statusDone stepStatus = iota + statusPending + statusOptional + statusRunning + statusFailed +) + +type dashboardStep struct { + title string + status stepStatus + detail string +} + +func tuiHeader(title string) string { + return tuiTitle.Render("🐾 " + title) + "\n\n" +} + +func tuiAction(pairs ...string) string { + var parts []string + for i := 0; i+1 < len(pairs); i += 2 { + parts = append(parts, tuiLabel.Render(pairs[i])+" "+tuiHint.Render(pairs[i+1])) + } + return tuiActionBar.Render(strings.Join(parts, " · ")) +} + +func tuiStatusLine(icon, label, detail string, focused bool) string { + line := fmt.Sprintf(" %s %s", icon, tuiLabel.Render(label)) + if detail != "" { + line += "\n " + tuiHint.Render(detail) + } + if focused { + return tuiCardFocusedStyle.Width(tuiStepWidth).Render(line) + } + return tuiCardStyle.Width(tuiStepWidth).Render(line) +} + +func tuiFormField(label string, input textinput.Model) string { + return fmt.Sprintf("%s\n%s", + tuiLabel.Render(label), + input.View()) +} + +func tuiInfo(text string) string { + return tuiInfoBox.Render(tuiHint.Render("ℹ " + text)) +} + +type progressStep struct { + label string + done bool +} + +func tuiProgress(steps []progressStep) string { + var b strings.Builder + for _, s := range steps { + if s.done { + b.WriteString(" " + tuiIconOK + " " + s.label + "\n") + } else if len(steps) > 0 && s == steps[0] { + continue + } else { + b.WriteString(" " + tuiIconOpt + " " + s.label + "\n") + } + } + return b.String() +} + +func tuiWizardCard(title, desc, body string) string { + var b strings.Builder + b.WriteString(tuiSection.Render(title)) + if desc != "" { + b.WriteString("\n") + b.WriteString(tuiHint.Render(desc)) + } + b.WriteString("\n\n") + b.WriteString(tuiCardStyle.Render(body)) + return b.String() +} + +func tuiResultCard(title string, pairs ...string) string { + var b strings.Builder + b.WriteString(tuiOK.Render(title) + "\n\n") + for i := 0; i+1 < len(pairs); i += 2 { + b.WriteString(tuiLabel.Render(pairs[i]) + " " + tuiPassword.Render(pairs[i+1]) + "\n") + } + return tuiCardStyle.Render(b.String()) +} + +func tuiErrorBanner(msg string) string { + return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg)) +} + +func tuiSuccessBanner(msg string) string { + return tuiCardFocusedStyle.Render(tuiOK.Render("✓ " + msg)) +} + +func tuiInput(placeholder string, charLimit int, password bool) textinput.Model { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = charLimit + ti.Width = 44 + ti.Prompt = "" + ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(cWhite) + if password { + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + } + return ti +} + +func tuiSeparator() string { + return tuiHint.Render(strings.Repeat("─", 70)) +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 9133b8d..5bcfd55 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -11,20 +11,23 @@ # install bundle (CRD + namespaces + RBAC + NetworkPolicies + control-plane # Deployments + in-cluster registry). # -# By design it stops short of serving the web panel. After it finishes you run -# `felis setup` on the host (a TUI) to create the Owner account and optionally -# configure the Cloudflare edge. See deploy/README.md. +# The recommended entrypoint is now `sudo felis setup`, which wraps this +# bootstrap in a TUI and then continues to the Owner/edge setup. This script +# remains usable directly for raw host provisioning. # # The script is idempotent: re-running it converges rather than duplicating, and # generated secrets are persisted to /etc/felis/secrets.env so reruns reuse them. # # Tunables (export before running to override the demo defaults): -# FELIS_REPO_URL git URL to build from (default: the upstream repo) -# FELIS_REF branch/tag/sha (default: main) +# FELIS_REPO_URL git URL to build from (raw script mode only) +# FELIS_REF branch/tag/sha (raw script mode only) # FELIS_IMAGE local image tag (default: felis:demo — never :latest) # FELIS_ROOT_DOMAIN deployment root domain (default: .nip.io) +# FELIS_PANEL_NODEPORT local HTTPS panel/API NodePort (default: 30443) # FELIS_EGRESS_MODE loadbalancer|nodeport (default: nodeport — no MetalLB on a demo box) -set -euo pipefail +# PKG_LOCK_TIMEOUT seconds to wait for package-manager locks (default: 900) +# APT_LOCK_TIMEOUT legacy alias for PKG_LOCK_TIMEOUT +set -Eeuo pipefail # --------------------------------------------------------------------------- # Configuration & constants @@ -33,6 +36,9 @@ FELIS_REPO_URL="${FELIS_REPO_URL:-https://github.com/MliroLirrorsIngenuity/Felis FELIS_REF="${FELIS_REF:-main}" FELIS_IMAGE="${FELIS_IMAGE:-felis:demo}" FELIS_EGRESS_MODE="${FELIS_EGRESS_MODE:-nodeport}" +FELIS_PANEL_NODEPORT="${FELIS_PANEL_NODEPORT:-30443}" +PKG_LOCK_TIMEOUT="${PKG_LOCK_TIMEOUT:-${APT_LOCK_TIMEOUT:-900}}" +APT_LOCK_TIMEOUT="${APT_LOCK_TIMEOUT:-$PKG_LOCK_TIMEOUT}" CONTROL_NS="felis" MINECRAFT_NS="minecraft" @@ -45,10 +51,48 @@ REGISTRY_URL="registry.felis.svc:5000" STATE_DIR="/etc/felis" SECRETS_ENV="${STATE_DIR}/secrets.env" +BOOTSTRAP_DONE="${STATE_DIR}/bootstrap.done" +PANEL_TLS_CERT="${STATE_DIR}/panel-tls.crt" +PANEL_TLS_KEY="${STATE_DIR}/panel-tls.key" SRC_DIR="/opt/felis/src" HOST_BIN="/usr/local/bin/felis" K3S_BIN_DIR="${K3S_BIN_DIR:-/usr/local/bin}" K3S_BIN="${K3S_BIN_DIR}/k3s" +APT_LOCK_FILES=( + /var/lib/dpkg/lock-frontend + /var/lib/dpkg/lock + /var/cache/apt/archives/lock + /var/lib/apt/lists/lock +) +APT_BACKGROUND_TIMERS=( + apt-daily.timer + apt-daily-upgrade.timer +) +APT_BACKGROUND_SERVICES=( + apt-daily.service + apt-daily-upgrade.service + unattended-upgrades.service +) +DNF_BACKGROUND_TIMERS=( + dnf-makecache.timer + dnf-automatic.timer +) +DNF_BACKGROUND_SERVICES=( + dnf-makecache.service + dnf-automatic.service +) +YUM_BACKGROUND_TIMERS=( + yum-cron.timer +) +YUM_BACKGROUND_SERVICES=( + yum-cron.service +) +ZYPPER_BACKGROUND_TIMERS=( + packagekit-background.timer +) +ZYPPER_BACKGROUND_SERVICES=( + packagekit.service +) # --------------------------------------------------------------------------- # Clean PATH (sudo may strip /usr/local/bin) @@ -64,6 +108,37 @@ ok() { printf '\033[1;32m[ ok ]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } +TEMP_PATHS=() +DOCKER_CONTAINERS=() +PKG_TIMERS_TO_RESTORE=() + +on_error() { + local line="$1" code="$2" + warn "bootstrap failed near line ${line} (exit ${code})" +} + +cleanup() { + local id path unit + for unit in "${PKG_TIMERS_TO_RESTORE[@]-}"; do + [ -n "$unit" ] || continue + systemctl start "$unit" >/dev/null 2>&1 || true + done + if command -v docker >/dev/null 2>&1; then + for id in "${DOCKER_CONTAINERS[@]-}"; do + [ -n "$id" ] && docker rm "$id" >/dev/null 2>&1 || true + done + fi + for path in "${TEMP_PATHS[@]-}"; do + [ -n "$path" ] && rm -rf -- "$path" || true + done +} + +remember_temp() { TEMP_PATHS+=("$1"); } +remember_container() { DOCKER_CONTAINERS+=("$1"); } + +trap 'on_error "$LINENO" "$?"' ERR +trap cleanup EXIT + k3s_cmd() { [ -x "$K3S_BIN" ] || die "k3s binary not found at ${K3S_BIN}"; "$K3S_BIN" "$@"; } kube() { k3s_cmd kubectl "$@"; } @@ -75,12 +150,161 @@ as_postgres() { fi } +bootstrap_from_tui() { + [ "${FELIS_BOOTSTRAP_FROM_TUI:-}" = "1" ] +} + +pause_package_background_timers() { + command -v systemctl >/dev/null 2>&1 || return 0 + + local active=0 timers=() services=() unit + case "${PKG:-}" in + apt) timers=("${APT_BACKGROUND_TIMERS[@]}"); services=("${APT_BACKGROUND_SERVICES[@]}") ;; + dnf) timers=("${DNF_BACKGROUND_TIMERS[@]}"); services=("${DNF_BACKGROUND_SERVICES[@]}") ;; + yum) timers=("${YUM_BACKGROUND_TIMERS[@]}"); services=("${YUM_BACKGROUND_SERVICES[@]}") ;; + zypper) timers=("${ZYPPER_BACKGROUND_TIMERS[@]}"); services=("${ZYPPER_BACKGROUND_SERVICES[@]}") ;; + *) return 0 ;; + esac + + for unit in "${timers[@]}"; do + if systemctl is-active --quiet "$unit"; then + PKG_TIMERS_TO_RESTORE+=("$unit") + active=1 + fi + done + if [ "$active" -eq 1 ]; then + log "pausing package-manager timers during bootstrap: ${PKG_TIMERS_TO_RESTORE[*]}" + systemctl stop "${PKG_TIMERS_TO_RESTORE[@]}" || warn "could not stop package-manager timers; package operations may need to wait" + fi + for unit in "${services[@]}"; do + if systemctl is-active --quiet "$unit"; then + log "stopping package-manager background service during bootstrap: ${unit}" + systemctl stop "$unit" || warn "could not stop ${unit}; package operations may need to wait" + fi + done +} + +pkg_lock_files() { + case "${PKG:-}" in + apt) printf '%s\n' "${APT_LOCK_FILES[@]}" ;; + dnf|yum) + printf '%s\n' \ + /var/lib/rpm/.rpm.lock \ + /var/lib/dnf/rpmdb_lock.pid \ + /var/cache/dnf/metadata_lock.pid \ + /run/dnf.pid \ + /var/run/dnf.pid + ;; + zypper) + printf '%s\n' \ + /var/lib/rpm/.rpm.lock \ + /run/zypp.pid \ + /var/run/zypp.pid + ;; + pacman) printf '%s\n' /var/lib/pacman/db.lck ;; + esac +} + +pkg_lock_process_names() { + case "${PKG:-}" in + dnf) printf '%s\n' dnf dnf5 rpm ;; + yum) printf '%s\n' yum rpm ;; + zypper) printf '%s\n' zypper rpm ;; + pacman) printf '%s\n' pacman ;; + esac +} + +pkg_busy_pids() { + local file file_count name + { + if command -v fuser >/dev/null 2>&1; then + local files=() + file_count=0 + while IFS= read -r file; do + if [ -e "$file" ]; then + files+=("$file") + file_count=$((file_count + 1)) + fi + done < <(pkg_lock_files) + [ "$file_count" -eq 0 ] || fuser "${files[@]}" 2>/dev/null | tr ' ' '\n' + fi + if command -v pgrep >/dev/null 2>&1; then + while IFS= read -r name; do + [ -n "$name" ] && pgrep -x "$name" 2>/dev/null || true + done < <(pkg_lock_process_names) + fi + } | awk 'NF && !seen[$1]++' +} + +pkg_lock_busy() { + [ -n "$(pkg_busy_pids)" ] +} + +pkg_lock_holders() { + local pids + pids="$(pkg_busy_pids | paste -sd, - || true)" + [ -n "$pids" ] || return 0 + ps -o pid=,comm= -p "$pids" 2>/dev/null | awk '{$1=$1; print}' | paste -sd ';' - +} + +wait_for_pkg_locks() { + local deadline holders next_notice + deadline=$((SECONDS + PKG_LOCK_TIMEOUT)) + next_notice=0 + while pkg_lock_busy; do + if [ "$SECONDS" -ge "$next_notice" ]; then + holders="$(pkg_lock_holders)" + if [ -n "$holders" ]; then + log "waiting for ${PKG} package locks to clear (timeout ${PKG_LOCK_TIMEOUT}s; holders: ${holders})" + else + log "waiting for ${PKG} package locks to clear (timeout ${PKG_LOCK_TIMEOUT}s)" + fi + next_notice=$((SECONDS + 30)) + fi + [ "$SECONDS" -lt "$deadline" ] || die "${PKG} package manager is still busy after ${PKG_LOCK_TIMEOUT}s; wait for the current package operation to finish, then retry" + sleep 5 + done +} + +apt_get() { + wait_for_pkg_locks + DEBIAN_FRONTEND=noninteractive apt-get \ + -o DPkg::Lock::Timeout="$PKG_LOCK_TIMEOUT" \ + "$@" +} + +validate_timeout() { + local name="$1" value="$2" + case "$value" in + ''|*[!0-9]*) die "${name} must be a non-negative integer (seconds), got: ${value}" ;; + esac +} + +validate_nodeport() { + local name="$1" value="$2" + case "$value" in + ''|*[!0-9]*) die "${name} must be a Kubernetes NodePort integer, got: ${value}" ;; + esac + if [ "$value" -lt 30000 ] || [ "$value" -gt 32767 ]; then + die "${name} must be in Kubernetes NodePort range 30000-32767, got: ${value}" + fi +} + +validate_settings() { + validate_timeout PKG_LOCK_TIMEOUT "$PKG_LOCK_TIMEOUT" + validate_timeout APT_LOCK_TIMEOUT "$APT_LOCK_TIMEOUT" + validate_nodeport FELIS_PANEL_NODEPORT "$FELIS_PANEL_NODEPORT" +} + # --------------------------------------------------------------------------- # 0. Privilege & host facts # --------------------------------------------------------------------------- if [ "$(id -u)" -ne 0 ]; then - log "re-executing under sudo" - exec sudo -E bash "$0" "$@" + if [ -r "$0" ]; then + log "re-executing under sudo" + exec sudo -E bash "$0" "$@" + fi + die "must run as root (for a piped installer, use: curl -fsSL | sudo bash)" fi detect_os() { @@ -117,21 +341,21 @@ detect_node_ip() { pkg_install() { case "$PKG" in - apt) DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;; - dnf) dnf install -y "$@" ;; - yum) yum install -y "$@" ;; - zypper) zypper --non-interactive install -y "$@" ;; - pacman) pacman -S --noconfirm --needed "$@" ;; + apt) apt_get install -y "$@" ;; + dnf) wait_for_pkg_locks; dnf install -y "$@" ;; + yum) wait_for_pkg_locks; yum install -y "$@" ;; + zypper) wait_for_pkg_locks; zypper --non-interactive install -y "$@" ;; + pacman) wait_for_pkg_locks; pacman -S --noconfirm --needed "$@" ;; esac } pkg_refresh_once() { [ -n "${_PKG_REFRESHED:-}" ] && return 0 case "$PKG" in - apt) DEBIAN_FRONTEND=noninteractive apt-get update -y ;; + apt) apt_get update -y ;; dnf|yum) : ;; # dnf/yum refresh metadata on demand - zypper) zypper --non-interactive refresh ;; - pacman) pacman -Syu --noconfirm ;; + zypper) wait_for_pkg_locks; zypper --non-interactive refresh ;; + pacman) wait_for_pkg_locks; pacman -Syu --noconfirm ;; esac _PKG_REFRESHED=1 } @@ -166,8 +390,15 @@ ensure_swap() { # 2. Base packages # --------------------------------------------------------------------------- install_base() { + local packages=(ca-certificates openssl) + pkg_refresh_once - pkg_install curl ca-certificates git openssl + command -v curl >/dev/null 2>&1 || packages+=(curl) + if ! bootstrap_from_tui && ! command -v git >/dev/null 2>&1; then + packages+=(git) + fi + + pkg_install "${packages[@]}" ok "base tools present" } @@ -186,6 +417,7 @@ install_cloudflared() { esac url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}" tmp="$(mktemp)" + remember_temp "$tmp" log "installing cloudflared (${arch})" curl -fsSL "$url" -o "$tmp" install -m 0755 "$tmp" /usr/local/bin/cloudflared @@ -226,12 +458,15 @@ install_docker_apt() { deb [arch=${arch} signed-by=${keyring}] https://download.docker.com/linux/${repo_os} ${OS_CODENAME} stable EOF - DEBIAN_FRONTEND=noninteractive apt-get update -y + apt_get update -y pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin } docker_rpm_repo_url() { case "$OS_ID" in + fedora) + printf '%s\n' "https://download.docker.com/linux/fedora/docker-ce.repo" + ;; rhel) printf '%s\n' "https://download.docker.com/linux/rhel/docker-ce.repo" ;; @@ -303,6 +538,7 @@ configure_k3s_firewall() { log "configuring firewalld for k3s" firewall-cmd --permanent --add-port=6443/tcp + firewall-cmd --permanent --add-port="${FELIS_PANEL_NODEPORT}/tcp" firewall-cmd --permanent --zone=trusted --add-source="$POD_CIDR" firewall-cmd --permanent --zone=trusted --add-source="$SERVICE_CIDR" firewall-cmd --reload @@ -339,7 +575,7 @@ install_k3s() { } # --------------------------------------------------------------------------- -# 5. Source + image build + host binary + containerd import +# 5. Source/binary + image build + containerd import # --------------------------------------------------------------------------- fetch_source() { if [ -n "${FELIS_SKIP_FETCH:-}" ]; then @@ -360,19 +596,69 @@ fetch_source() { ok "source ready at ${SRC_DIR}" } -build_image() { - systemctl start docker +install_embedded_binary() { + local src + src="${FELIS_BOOTSTRAP_BINARY:-}" + [ -n "$src" ] || die "FELIS_BOOTSTRAP_BINARY is not set; cannot install the embedded setup binary" + [ -x "$src" ] || die "FELIS_BOOTSTRAP_BINARY is not executable: ${src}" + + mkdir -p "$(dirname "$HOST_BIN")" + if [ "$(readlink -f "$src")" != "$(readlink -f "$HOST_BIN" 2>/dev/null || true)" ]; then + log "installing current felis binary onto the host (${HOST_BIN})" + install -m 0755 "$src" "$HOST_BIN" + else + ok "host binary already installed at ${HOST_BIN}" + fi +} + +build_image_from_binary() { + local tmp + tmp="$(mktemp -d)" + remember_temp "$tmp" + cp "$HOST_BIN" "${tmp}/felis" + cat > "${tmp}/Dockerfile" <<'EOF' +FROM gcr.io/distroless/base-debian12:nonroot +ENV PATH=/usr/local/bin:/usr/bin:/bin +COPY felis /usr/local/bin/felis +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/felis"] +EOF + chmod 0755 "${tmp}/felis" + + log "building ${FELIS_IMAGE} from the current felis binary" + docker build -t "$FELIS_IMAGE" "$tmp" + rm -rf "$tmp" +} + +verify_image_starts() { + log "verifying ${FELIS_IMAGE} starts" + docker run --rm --user 1000:1000 --entrypoint /usr/local/bin/felis "$FELIS_IMAGE" help >/dev/null +} + +build_image_from_source() { log "building ${FELIS_IMAGE} (this compiles the Go binary; first run is slow)" docker build -t "$FELIS_IMAGE" "$SRC_DIR" log "extracting the felis binary onto the host (${HOST_BIN})" local cid cid="$(docker create "$FELIS_IMAGE")" + remember_container "$cid" docker cp "${cid}:/usr/local/bin/felis" "$HOST_BIN" docker rm "$cid" >/dev/null chmod 0755 "$HOST_BIN" +} + +build_image() { + systemctl start docker + if bootstrap_from_tui; then + build_image_from_binary + else + build_image_from_source + fi + verify_image_starts log "importing ${FELIS_IMAGE} into k3s containerd" + remove_k3s_image "$FELIS_IMAGE" docker save "$FELIS_IMAGE" | k3s_cmd ctr images import - # Reclaim the ~150 MiB the docker daemon holds; reruns restart it on demand. @@ -380,15 +666,27 @@ build_image() { ok "image built, binary on host, image imported" } +remove_k3s_image() { + local image="$1" + k3s_cmd ctr images rm "$image" >/dev/null 2>&1 || true + case "$image" in + */*) ;; + *) k3s_cmd ctr images rm "docker.io/library/${image}" >/dev/null 2>&1 || true ;; + esac +} + # --------------------------------------------------------------------------- # 6. PostgreSQL on the host. felis-api pods reach it at :5432; # migrations run from the host binary against 127.0.0.1. # --------------------------------------------------------------------------- write_pg_hba_block() { - local hba="$1" tmp node_cidr + local hba="$1" tmp tmp_new node_cidr node_cidr="${NODE_IP}/32" tmp="$(mktemp)" + tmp_new="${tmp}.new" + remember_temp "$tmp" + remember_temp "$tmp_new" awk \ -v db="$DB_NAME" \ @@ -415,10 +713,10 @@ write_pg_hba_block() { printf "# END FELIS MANAGED HBA\n" printf "\n" cat "$tmp" - } > "${tmp}.new" + } > "$tmp_new" - cat "${tmp}.new" > "$hba" - rm -f "$tmp" "${tmp}.new" + cat "$tmp_new" > "$hba" + rm -f "$tmp" "$tmp_new" } postgres_data_dir() { @@ -512,15 +810,61 @@ load_or_make_secrets() { DB_PASSWORD="${DB_PASSWORD:-$(openssl rand -hex 24)}" SERVICE_TOKEN="${SERVICE_TOKEN:-$(openssl rand -hex 32)}" SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}" - umask 077 - cat > "$SECRETS_ENV" < "$SECRETS_ENV" < "$conf" </dev/null 2>&1 + chmod 0600 "$PANEL_TLS_KEY" + chmod 0644 "$PANEL_TLS_CERT" + ok "panel TLS certificate ready (${PANEL_TLS_CERT})" +} + write_felis_toml() { local target="$1" db_host="$2" cat > "$target" < ${STATE_DIR}/felis.host.toml" + return 0 + fi warn "leaving existing ${target}; setup can use -config ${STATE_DIR}/felis.host.toml if needed" return 0 fi @@ -576,11 +928,19 @@ run_migrations() { } deploy_bundle() { + local had_api=0 had_operator=0 export KUBECONFIG=/etc/rancher/k3s/k3s.yaml write_felis_toml "${STATE_DIR}/felis.pod.toml" "${NODE_IP}" + kube -n "$CONTROL_NS" get deployment felis-api >/dev/null 2>&1 && had_api=1 + kube -n "$CONTROL_NS" get deployment felis-operator >/dev/null 2>&1 && had_operator=1 + log "applying MinecraftServer CRD" - kube apply -f "${SRC_DIR}/deploy/crd/" + if bootstrap_from_tui; then + "$HOST_BIN" bootstrap-assets crd | kube apply -f - + else + kube apply -f "${SRC_DIR}/deploy/crd/" + fi log "ensuring namespaces" local ns @@ -588,27 +948,74 @@ deploy_bundle() { kube create namespace "$ns" --dry-run=client -o yaml | kube apply -f - done - log "provisioning felis-config + felis-service-token secrets (out-of-band, never in the bundle)" + log "provisioning felis-config + felis-service-token + panel TLS secrets (out-of-band, never in the bundle)" kube -n "$CONTROL_NS" create secret generic felis-config \ --from-file=felis.toml="${STATE_DIR}/felis.pod.toml" \ --dry-run=client -o yaml | kube apply -f - kube -n "$CONTROL_NS" create secret generic felis-service-token \ --from-literal=token="${SERVICE_TOKEN}" \ --dry-run=client -o yaml | kube apply -f - + kube -n "$CONTROL_NS" create secret tls felis-api-tls \ + --cert="$PANEL_TLS_CERT" \ + --key="$PANEL_TLS_KEY" \ + --dry-run=client -o yaml | kube apply -f - log "rendering + applying the control-plane bundle" "$HOST_BIN" manifests \ --felis-image "$FELIS_IMAGE" \ + --panel-node-port "$FELIS_PANEL_NODEPORT" \ --velocity-cidr "${NODE_IP}/32" \ | kube apply -f - + restart_existing_control_plane "$had_api" "$had_operator" log "waiting for control-plane rollouts" local d for d in $(kube -n "$CONTROL_NS" get deploy -o name); do - kube -n "$CONTROL_NS" rollout status "$d" --timeout=180s || warn "rollout not complete: $d" + if ! kube -n "$CONTROL_NS" rollout status "$d" --timeout=180s; then + diagnose_rollout "$d" + die "control-plane rollout did not complete: ${d}" + fi done } +restart_existing_control_plane() { + local had_api="$1" had_operator="$2" + [ "$had_api$had_operator" != "00" ] || return 0 + + log "restarting existing control-plane deployments to pick up ${FELIS_IMAGE}" + [ "$had_api" = "1" ] && kube -n "$CONTROL_NS" rollout restart deployment/felis-api + [ "$had_operator" = "1" ] && kube -n "$CONTROL_NS" rollout restart deployment/felis-operator +} + +diagnose_rollout() { + local deploy="$1" name selector pod + name="${deploy##*/}" + warn "rollout not complete: ${deploy}" + kube -n "$CONTROL_NS" describe "$deploy" || true + + case "$name" in + felis-api) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=api' ;; + felis-operator) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=operator' ;; + registry) selector='app.kubernetes.io/name=felis,app.kubernetes.io/component=registry' ;; + *) selector='' ;; + esac + [ -n "$selector" ] || return 0 + + kube -n "$CONTROL_NS" get pods -l "$selector" -o wide || true + for pod in $(kube -n "$CONTROL_NS" get pods -l "$selector" -o name 2>/dev/null); do + warn "pod detail: ${pod}" + kube -n "$CONTROL_NS" describe "$pod" || true + warn "recent logs: ${pod}" + kube -n "$CONTROL_NS" logs "$pod" --all-containers --tail=120 || true + kube -n "$CONTROL_NS" logs "$pod" --all-containers --previous --tail=120 || true + done +} + +mark_bootstrap_done() { + date -u +%Y-%m-%dT%H:%M:%SZ > "$BOOTSTRAP_DONE" + chmod 0644 "$BOOTSTRAP_DONE" +} + # --------------------------------------------------------------------------- # 9. Summary # --------------------------------------------------------------------------- @@ -619,27 +1026,41 @@ summary() { echo kube -n "$CONTROL_NS" get pods -o wide || true echo - log "Web is intentionally NOT enabled yet." - log "Next: run 'sudo felis setup' on this host to create the Owner account and configure the web edge." + log "Panel URL: https://${NODE_IP}:${FELIS_PANEL_NODEPORT}" + log "DNS alias (if your resolver supports it): https://op.console.${FELIS_ROOT_DOMAIN}:${FELIS_PANEL_NODEPORT}" + log "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit." + if [ "${FELIS_BOOTSTRAP_FROM_TUI:-}" = "1" ]; then + log "Returning to the setup console to create the Owner account and verify panel access." + else + log "Next: run 'sudo felis setup' on this host to create the Owner account." + fi log "Use 'sudo felis breakGlass' only for emergency local Owner recovery/reset." echo } main() { + validate_settings detect_os + pause_package_background_timers detect_node_ip ensure_swap install_base install_cloudflared load_or_make_secrets + ensure_panel_tls_cert install_docker install_k3s - fetch_source + if bootstrap_from_tui; then + install_embedded_binary + else + fetch_source + fi build_image install_postgres configure_postgres run_migrations deploy_bundle + mark_bootstrap_done summary } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e35624f..912dd24 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1058,6 +1058,16 @@ func TestSessionAuthUsesConfiguredAdminHostname(t *testing.T) { if p.ViaAdminAccess { t.Fatalf("root-domain fallback host must not grant admin-path access when admin_hostname is configured") } + + r = httptest.NewRequest("GET", "https://10.211.55.4:30443/api/v1/me", nil) + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: token}) + p, err = auth.Authenticate(r) + if err != nil { + t.Fatalf("Authenticate private IP host: %v", err) + } + if !p.ViaAdminAccess { + t.Fatalf("private IP local panel should grant admin-path access, got %+v", p) + } } func TestAccessVerifier(t *testing.T) { key := []byte("test-signing-key") diff --git a/internal/api/session.go b/internal/api/session.go index 8b6fce1..315fc6d 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -88,6 +88,8 @@ func clearSessionCookie(w http.ResponseWriter) { // 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.. +// Local bootstrap may also use the node's private/loopback IP directly when +// wildcard DNS is unavailable; that is treated as the local admin face. func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool { want := strings.TrimSpace(adminHostname) if want == "" { @@ -100,6 +102,9 @@ func hostIsAdminConsole(r *http.Request, rootDomain, adminHostname string) bool if h, _, err := net.SplitHostPort(host); err == nil { host = h } + if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { + return ip.IsLoopback() || ip.IsPrivate() + } return strings.EqualFold(strings.TrimSuffix(host, "."), strings.TrimSuffix(want, ".")) } diff --git a/internal/cfsetup/cfsetup.go b/internal/cfsetup/cfsetup.go index 3d746c8..aa99610 100644 --- a/internal/cfsetup/cfsetup.go +++ b/internal/cfsetup/cfsetup.go @@ -32,9 +32,8 @@ import ( ) // defaultPanelOrigin is where the tunnel forwards the web hostnames when the -// caller does not override it: the felis-api listen port (config defaultListen -// is 0.0.0.0:8080), reachable on the box as loopback. -const defaultPanelOrigin = "http://localhost:8080" +// caller does not override it: the local HTTPS NodePort exposed by bootstrap. +const defaultPanelOrigin = "https://127.0.0.1:30443" // defaultSessionDuration is the recommended Access session length when unset. const defaultSessionDuration = "24h" @@ -253,8 +252,13 @@ type tunnelConfig struct { // ingressRule is one cloudflared ingress entry. A rule with an empty Hostname is // the catch-all (must be last). type ingressRule struct { - Hostname string `json:"hostname,omitempty"` - Service string `json:"service"` + Hostname string `json:"hostname,omitempty"` + Service string `json:"service"` + OriginRequest *originRequest `json:"originRequest,omitempty"` +} + +type originRequest struct { + NoTLSVerify bool `json:"noTLSVerify,omitempty"` } // BuildTunnelConfig renders the cloudflared config.yml that routes each web @@ -273,11 +277,20 @@ func BuildTunnelConfig(tunnelID, credentialsFile, panelOrigin string, hostnames return nil, errors.New("cfsetup: at least one web hostname is required") } cfg := tunnelConfig{Tunnel: tunnelID, CredentialsFile: credentialsFile} + seen := map[string]struct{}{} for _, h := range hostnames { if h == "" { return nil, errors.New("cfsetup: empty hostname in ingress") } - cfg.Ingress = append(cfg.Ingress, ingressRule{Hostname: h, Service: panelOrigin}) + if _, ok := seen[h]; ok { + continue + } + seen[h] = struct{}{} + rule := ingressRule{Hostname: h, Service: panelOrigin} + if strings.HasPrefix(panelOrigin, "https://") { + rule.OriginRequest = &originRequest{NoTLSVerify: true} + } + cfg.Ingress = append(cfg.Ingress, rule) } // The mandatory trailing catch-all: anything not explicitly routed gets a bare // 404, never a forward to the origin. @@ -339,13 +352,14 @@ type Runner interface { type Params struct { PanelHostname string // console. (Player web) AdminHostname string // op.console. (Operator+SysAdmin web) - PanelOrigin string // where the tunnel forwards; default http://localhost:8080 + PanelOrigin string // where the tunnel forwards; default HTTPS NodePort origin TunnelName string ConfigPath string // where to write config.yml SessionDuration string AllowedIdPs []string // restrict the Access app to these IdPs (SSO) AccessIdentity AccessIdentity // WHO the policy admits (fail-closed) Pre Preconditions + OnProgress func(string) // optional, called at each step for TUI display } // Result reports what Setup produced, including the Access `aud` the caller must @@ -357,6 +371,7 @@ type Result struct { AccessAppID string AccessAud string RoutedHostnames []string + Progress []string // ordered steps completed, for TUI display } // Setup runs the recommended Cloudflare Tunnel + Access provisioning end to end @@ -376,6 +391,12 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { if p.TunnelName == "" { return nil, errors.New("cfsetup: tunnel name is required") } + notify := func(s string) { + if p.OnProgress != nil { + p.OnProgress(s) + } + } + var prog []string // 1. Gate on operator-only preconditions — no side effects on failure. if err := p.Pre.check(); err != nil { return nil, err @@ -387,16 +408,16 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { return nil, err } if err := validateFailClosed(policy); err != nil { - return nil, err // belt-and-suspenders: never POST an open policy + return nil, err } // 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. + // check before creating tunnels or DNS records. if verifier, ok := runner.(apiTokenVerifier); ok { + notify("Verifying API token…") if err := verifier.VerifyAPIToken(ctx); err != nil { return nil, fmt.Errorf("cfsetup: verify Cloudflare API token: %w", err) } + prog = append(prog, "Verified API token") } hostnames := webHostnames(p) @@ -406,17 +427,23 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { } // 4. Create the tunnel. + notify("Creating tunnel " + p.TunnelName + "…") id, cred, err := runner.CreateTunnel(ctx, p.TunnelName) if err != nil { return nil, fmt.Errorf("cfsetup: create tunnel: %w", err) } - // 5. Route DNS for each WEB hostname only (the game host stays off the tunnel). + prog = append(prog, "Created tunnel") + + // 5. Route DNS for each WEB hostname only. for _, h := range hostnames { + notify("Routing DNS " + h + "…") if err := runner.RouteDNS(ctx, id, h); err != nil { return nil, fmt.Errorf("cfsetup: route dns %s: %w", h, err) } + prog = append(prog, "Routed "+h) } // 6. Render and persist the ingress config. + notify("Writing tunnel config…") cfgBytes, err := BuildTunnelConfig(id, cred, origin, hostnames) if err != nil { return nil, err @@ -425,17 +452,22 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { if err := runner.WriteTunnelConfig(p.ConfigPath, cfgBytes); err != nil { return nil, fmt.Errorf("cfsetup: write config: %w", err) } + prog = append(prog, "Wrote "+p.ConfigPath) } // 7. Front the admin face with a self-hosted Access app. + notify("Creating Access application…") 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) } + prog = append(prog, "Created Access app") // 8. Attach the guarded fail-closed policy. + notify("Attaching Access policy…") if err := runner.CreateAccessPolicy(ctx, appID, policy); err != nil { return nil, fmt.Errorf("cfsetup: create access policy: %w", err) } + prog = append(prog, "Attached Access policy") return &Result{ TunnelID: id, @@ -444,6 +476,7 @@ func Setup(ctx context.Context, runner Runner, p Params) (*Result, error) { AccessAppID: appID, AccessAud: aud, RoutedHostnames: hostnames, + Progress: prog, }, nil } diff --git a/internal/cfsetup/cfsetup_test.go b/internal/cfsetup/cfsetup_test.go index 25d7cbc..3d3b0e2 100644 --- a/internal/cfsetup/cfsetup_test.go +++ b/internal/cfsetup/cfsetup_test.go @@ -368,3 +368,30 @@ func TestIngressSafetyInvariants(t *testing.T) { } } } + +func TestIngressHTTPSOriginUsesNoTLSVerifyAndDeduplicatesHostnames(t *testing.T) { + const origin = "https://127.0.0.1:30443" + raw, err := BuildTunnelConfig( + "11111111-2222-3333-4444-555555555555", + "/root/.cloudflared/x.json", + origin, + []string{"op.console." + testRoot, "op.console." + testRoot}, + ) + if err != nil { + t.Fatalf("BuildTunnelConfig: %v", err) + } + var cfg tunnelConfig + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("generated config is not valid YAML: %v\n%s", err, raw) + } + if len(cfg.Ingress) != 2 { + t.Fatalf("ingress count = %d, want one routed host plus catch-all: %#v", len(cfg.Ingress), cfg.Ingress) + } + rule := cfg.Ingress[0] + if rule.Service != origin { + t.Fatalf("service = %q, want %q", rule.Service, origin) + } + if rule.OriginRequest == nil || !rule.OriginRequest.NoTLSVerify { + t.Fatalf("originRequest = %#v, want noTLSVerify=true", rule.OriginRequest) + } +} diff --git a/internal/panel/panel.go b/internal/panel/panel.go new file mode 100644 index 0000000..626c878 --- /dev/null +++ b/internal/panel/panel.go @@ -0,0 +1,91 @@ +// Package panel serves the embedded Felis control panel next to the external API. +package panel + +import ( + "bytes" + "embed" + "encoding/json" + "errors" + "io" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed static +var static embed.FS + +type runtimeConfig struct { + APIBase string `json:"apiBase"` + RootDomain string `json:"rootDomain"` +} + +// Handler wraps the external API handler with the panel SPA. +func Handler(api http.Handler, rootDomain string) http.Handler { + files, err := fs.Sub(static, "static") + if err != nil { + panic(err) + } + return &handler{ + api: api, + rootDomain: rootDomain, + files: files, + fileServer: http.FileServer(http.FS(files)), + } +} + +type handler struct { + api http.Handler + rootDomain string + files fs.FS + fileServer http.Handler +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/healthz" || r.URL.Path == "/readyz" || strings.HasPrefix(r.URL.Path, "/api/"): + h.api.ServeHTTP(w, r) + case r.URL.Path == "/config.json": + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(runtimeConfig{APIBase: "/api/v1", RootDomain: h.rootDomain}) + case h.hasStaticFile(r.URL.Path): + h.fileServer.ServeHTTP(w, r) + default: + h.serveIndex(w, r) + } +} + +func (h *handler) hasStaticFile(urlPath string) bool { + name := strings.TrimPrefix(path.Clean("/"+urlPath), "/") + if name == "" { + name = "index.html" + } + info, err := fs.Stat(h.files, name) + return err == nil && !info.IsDir() +} + +func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) { + f, err := h.files.Open("index.html") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + http.Error(w, "panel assets missing", http.StatusServiceUnavailable) + return + } + http.Error(w, "open panel index", http.StatusInternalServerError) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + http.Error(w, "stat panel index", http.StatusInternalServerError) + return + } + body, err := io.ReadAll(f) + if err != nil { + http.Error(w, "read panel index", http.StatusInternalServerError) + return + } + http.ServeContent(w, r, "index.html", info.ModTime(), bytes.NewReader(body)) +} diff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go new file mode 100644 index 0000000..b8a0f12 --- /dev/null +++ b/internal/panel/panel_test.go @@ -0,0 +1,50 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandlerServesPanelAndConfig(t *testing.T) { + api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/me" { + t.Fatalf("api saw unexpected path %q", r.URL.Path) + } + w.WriteHeader(http.StatusTeapot) + }) + h := Handler(api, "example.test") + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Felis") { + t.Fatalf("index response = %d %q", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/servers/survival", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Felis") { + t.Fatalf("spa fallback = %d %q", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config.json", nil)) + if w.Code != http.StatusOK { + t.Fatalf("config status = %d", w.Code) + } + var cfg runtimeConfig + if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" { + t.Fatalf("config = %+v", cfg) + } + + w = httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)) + if w.Code != http.StatusTeapot { + t.Fatalf("api status = %d", w.Code) + } +} diff --git a/internal/panel/static/index.html b/internal/panel/static/index.html new file mode 100644 index 0000000..bfb31b6 --- /dev/null +++ b/internal/panel/static/index.html @@ -0,0 +1,11 @@ + + + + + + Felis Control Panel + + +
Felis panel assets were not built into this binary.
+ + diff --git a/internal/platform/bundle.go b/internal/platform/bundle.go index 8e3ab13..41dbca4 100644 --- a/internal/platform/bundle.go +++ b/internal/platform/bundle.go @@ -35,11 +35,9 @@ type Object interface { // make the SAs and NetworkPolicy peers refer to something real (see workloads.go). // The reaper CronJob is also part of Workloads, rendered only when the retention // storage topology is supplied (WorldsHostPath + BackupPVC + ArchiveLocalPath — -// workloads.go documents the gate and the shape-asserted hostPath caveat). Still -// deliberately NOT rendered: a felis-api Service (its exposure is an out-of-band -// deployment choice and nothing in-tree dials it). The per-server StatefulSet is -// never a static manifest — the operator renders it at reconcile time -// (internal/operator). +// workloads.go documents the gate and the shape-asserted hostPath caveat). The +// per-server StatefulSet is never a static manifest — the operator renders it at +// reconcile time (internal/operator). func Objects(p Params) []Object { p = p.withDefaults() var objs []Object diff --git a/internal/platform/identities.go b/internal/platform/identities.go index 5a32f89..bd0fb99 100644 --- a/internal/platform/identities.go +++ b/internal/platform/identities.go @@ -53,6 +53,7 @@ const ( DefaultControlNamespace = "felis" DefaultMinecraftNamespace = "minecraft" DefaultBuildNamespace = "felis-build" + DefaultPanelNodePort = int32(30443) defaultRegistryPort int32 = 5000 @@ -88,6 +89,9 @@ type Params struct { // control namespace (registry co-located with the control plane). RegistryNamespace string RegistryPort int32 + // PanelNodePort exposes the built-in HTTPS panel/API origin from the node. + // It defaults to 30443 so a fresh setup can finish with a concrete browser URL. + PanelNodePort int32 // PackageSourceCIDRs is the explicit package-mirror egress allowlist for build // Pods (spec §16). Empty means no internet egress at all — the locked-down // default the build subsystem already enforces. @@ -159,6 +163,9 @@ func (p Params) withDefaults() Params { if p.RegistryPort == 0 { p.RegistryPort = defaultRegistryPort } + if p.PanelNodePort == 0 { + p.PanelNodePort = DefaultPanelNodePort + } if p.RegistryImage == "" { p.RegistryImage = defaultRegistryImage } diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 92f24c1..5c0e36a 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -40,15 +40,6 @@ import ( // the worlds to one node implicitly; a multi-node deployment MUST add one (or the // CronJob could schedule on a node where the hostPath is empty) — a hazard left on // record here until multi-node retention is built. -// -// Deliberately NOT rendered: -// - A Service for felis-api. Its external face (8080) is exposed out-of-band -// (Ingress/LoadBalancer is a deployment choice) and its internal face's only -// consumer is the Velocity plugin; nothing in-tree dials a felis-api Service -// name, so rendering one would be a speculative selector. The registry Service -// IS rendered because registry..svc:5000 is a pinned consumer hardcoded -// across the build subsystem and config. - const ( // configSecretName / serviceTokenSecretName are referenced BY NAME and NEVER // rendered into the bundle: felis.toml carries the database URL (a credential) @@ -59,6 +50,7 @@ const ( configSecretKey = "felis.toml" configMountPath = "/etc/felis" configFilePath = "/etc/felis/felis.toml" + felisBinaryPath = "/usr/local/bin/felis" serviceTokenSecretName = "felis-service-token" serviceTokenSecretKey = "token" @@ -67,7 +59,10 @@ const ( // agreement lives in the out-of-band config Secret and cannot be enforced here. apiExternalPort int32 = 8080 apiInternalPort int32 = 8081 + apiHTTPSPort int32 = 8443 operatorMetricsPort int32 = 8080 + apiTLSSecretName = "felis-api-tls" + apiTLSMountPath = "/etc/felis/tls" registryName = "registry" registryDataPath = "/var/lib/registry" @@ -113,6 +108,7 @@ func Workloads(p Params) []Object { p = p.withDefaults() objs := []Object{ APIDeployment(p), + apiService(p), OperatorDeployment(p), registryDeployment(p), registryService(p), @@ -169,18 +165,23 @@ func APIDeployment(p Params) *appsv1.Deployment { container := corev1.Container{ Name: ComponentAPI, Image: p.FelisImage, - Command: []string{"felis", "api"}, + Command: []string{felisBinaryPath, "api"}, Args: []string{ "--config", configFilePath, "--internal-addr", fmt.Sprintf(":%d", apiInternalPort), + "--https-addr", fmt.Sprintf(":%d", apiHTTPSPort), + "--tls-cert", apiTLSMountPath + "/tls.crt", + "--tls-key", apiTLSMountPath + "/tls.key", }, Env: env, Ports: []corev1.ContainerPort{ {Name: "external", ContainerPort: apiExternalPort, Protocol: corev1.ProtocolTCP}, + {Name: "https", ContainerPort: apiHTTPSPort, Protocol: corev1.ProtocolTCP}, {Name: "internal", ContainerPort: apiInternalPort, Protocol: corev1.ProtocolTCP}, }, VolumeMounts: []corev1.VolumeMount{ {Name: configVolume, MountPath: configMountPath, ReadOnly: true}, + {Name: "tls", MountPath: apiTLSMountPath, ReadOnly: true}, {Name: tmpVolume, MountPath: "/tmp"}, }, Resources: controlPlaneResources(), @@ -194,12 +195,39 @@ func APIDeployment(p Params) *appsv1.Deployment { Secret: &corev1.SecretVolumeSource{SecretName: configSecretName}, }, }, + { + Name: "tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: apiTLSSecretName}, + }, + }, {Name: tmpVolume, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, } return controlPlaneDeployment(p, SAAPI, container, volumes) } +// apiService exposes the built-in HTTPS panel/API origin as a stable NodePort. +func apiService(p Params) *corev1.Service { + p = p.withDefaults() + labels := controlPlanePodLabels(ComponentAPI) + return &corev1.Service{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, + ObjectMeta: metav1.ObjectMeta{Name: SAAPI, Namespace: p.ControlNamespace, Labels: labels}, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeNodePort, + Selector: labels, + Ports: []corev1.ServicePort{{ + Name: "https", + Port: 443, + TargetPort: intstr.FromString("https"), + NodePort: p.PanelNodePort, + Protocol: corev1.ProtocolTCP, + }}, + }, + } +} + // OperatorDeployment renders the felis-operator Deployment (spec §5). It runs as // the felis-operator SA and carries controlPlanePodLabels(operator), the second // pod the allow-rcon peer admits (the readiness prober dials RCON). It takes NO @@ -213,7 +241,7 @@ func OperatorDeployment(p Params) *appsv1.Deployment { container := corev1.Container{ Name: ComponentOperator, Image: p.FelisImage, - Command: []string{"felis", "operator"}, + Command: []string{felisBinaryPath, "operator"}, Args: []string{ "--namespace", p.MinecraftNamespace, "--metrics-bind-address", fmt.Sprintf(":%d", operatorMetricsPort), @@ -271,7 +299,7 @@ func reaperCronJob(p Params) *batchv1.CronJob { container := corev1.Container{ Name: ComponentReaper, Image: p.FelisImage, - Command: []string{"felis", "reaper"}, + Command: []string{felisBinaryPath, "reaper"}, Args: []string{ "--config", configFilePath, "--worlds-root", worldsMountPath, diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index 15af9d3..a036f7e 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -153,8 +153,8 @@ func TestAPIDeployment_Wiring(t *testing.T) { d := APIDeployment(p) ps, c := podSpec(t, d) - if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "api"}) { - t.Errorf("api command/args = %v, want it to start `felis api`", got) + if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "api"}) { + t.Errorf("api command/args = %v, want it to start `%s api`", got, felisBinaryPath) } if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) { t.Errorf("api args must mount config at %s, got %v", configFilePath, c.Args) @@ -162,6 +162,13 @@ func TestAPIDeployment_Wiring(t *testing.T) { if !contains(c.Args, "--internal-addr") { t.Errorf("api args must set --internal-addr, got %v", c.Args) } + if !contains(c.Args, "--https-addr") || !contains(c.Args, ":8443") { + t.Errorf("api args must set HTTPS listener, got %v", c.Args) + } + if !contains(c.Args, "--tls-cert") || !contains(c.Args, apiTLSMountPath+"/tls.crt") || + !contains(c.Args, "--tls-key") || !contains(c.Args, apiTLSMountPath+"/tls.key") { + t.Errorf("api args must point at mounted TLS secret, got %v", c.Args) + } if c.Image != p.FelisImage { t.Errorf("api image = %q, want FelisImage %q", c.Image, p.FelisImage) } @@ -194,6 +201,13 @@ func TestAPIDeployment_Wiring(t *testing.T) { if m := mountByName(c.VolumeMounts, configVolume); m == nil || !m.ReadOnly { t.Error("config volume must be mounted read-only") } + tlsVol := volumeByName(ps.Volumes, "tls") + if tlsVol == nil || tlsVol.Secret == nil || tlsVol.Secret.SecretName != apiTLSSecretName { + t.Fatalf("tls volume must mount Secret %q, got %#v", apiTLSSecretName, tlsVol) + } + if m := mountByName(c.VolumeMounts, "tls"); m == nil || !m.ReadOnly || m.MountPath != apiTLSMountPath { + t.Errorf("tls volume mount = %#v, want read-only at %s", m, apiTLSMountPath) + } // No backup PVC in testParams ⇒ no FELIS_BACKUP_PVC env (restore degrades to 503). if envVar(c.Env, "FELIS_BACKUP_PVC") != nil { @@ -201,6 +215,30 @@ func TestAPIDeployment_Wiring(t *testing.T) { } } +func TestAPIService_NodePort(t *testing.T) { + p := testParams() + p.PanelNodePort = 30445 + svc := apiService(p) + dep := APIDeployment(p) + + if svc.Name != SAAPI || svc.Namespace != p.ControlNamespace { + t.Errorf("api Service = %s/%s, want %s/%s", svc.Namespace, svc.Name, p.ControlNamespace, SAAPI) + } + if svc.Spec.Type != corev1.ServiceTypeNodePort { + t.Errorf("api Service type = %s, want NodePort", svc.Spec.Type) + } + if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) { + t.Errorf("api Service selector %v does not select api pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels) + } + if len(svc.Spec.Ports) != 1 { + t.Fatalf("api Service ports = %v, want one", svc.Spec.Ports) + } + port := svc.Spec.Ports[0] + if port.Port != 443 || port.TargetPort.StrVal != "https" || port.NodePort != p.PanelNodePort { + t.Errorf("api Service port = %#v, want 443 -> https NodePort %d", port, p.PanelNodePort) + } +} + // TestAPIDeployment_BackupPVC proves the FELIS_BACKUP_PVC env appears only when a // backup PVC is named. func TestAPIDeployment_BackupPVC(t *testing.T) { @@ -219,8 +257,8 @@ func TestOperatorDeployment_Wiring(t *testing.T) { d := OperatorDeployment(p) ps, c := podSpec(t, d) - if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "operator"}) { - t.Errorf("operator command/args = %v, want it to start `felis operator`", got) + if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "operator"}) { + t.Errorf("operator command/args = %v, want it to start `%s operator`", got, felisBinaryPath) } if !contains(c.Args, "--namespace") || !contains(c.Args, p.MinecraftNamespace) { t.Errorf("operator must watch --namespace %s, got %v", p.MinecraftNamespace, c.Args) @@ -299,12 +337,12 @@ func TestRegistry_DeploymentServicePVC(t *testing.T) { } // TestWorkloads_BundleContents sanity-checks the slice Workloads returns: the two -// control-plane Deployments + the registry Deployment/Service/PVC, every one with -// TypeMeta (so its YAML header renders). +// control-plane Deployments, the api Service, and the registry Deployment/Service/PVC, +// every one with TypeMeta (so its YAML header renders). func TestWorkloads_BundleContents(t *testing.T) { objs := Workloads(testParams()) - if len(objs) != 5 { - t.Fatalf("Workloads returned %d objects, want 5", len(objs)) + if len(objs) != 6 { + t.Fatalf("Workloads returned %d objects, want 6", len(objs)) } for _, o := range objs { gvk := o.GetObjectKind().GroupVersionKind() @@ -444,9 +482,9 @@ func TestReaperCronJob_Shape(t *testing.T) { t.Error("reaper container must drop ALL capabilities") } - // Entrypoint: `felis reaper --config --worlds-root /worlds`. - if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{"felis", "reaper"}) { - t.Errorf("reaper command/args = %v, want it to start `felis reaper`", got) + // Entrypoint: `/usr/local/bin/felis reaper --config --worlds-root /worlds`. + if got := append(append([]string{}, c.Command...), c.Args...); !containsSeq(got, []string{felisBinaryPath, "reaper"}) { + t.Errorf("reaper command/args = %v, want it to start `%s reaper`", got, felisBinaryPath) } if !contains(c.Args, "--config") || !contains(c.Args, configFilePath) { t.Errorf("reaper must read config at %s, got %v", configFilePath, c.Args) diff --git a/internal/restore/jobspec.go b/internal/restore/jobspec.go index 31930aa..886b257 100644 --- a/internal/restore/jobspec.go +++ b/internal/restore/jobspec.go @@ -20,8 +20,9 @@ const ( managedByValue = "felis-restore" componentValue = "world-restore" - worldVolume = "world" - backupVolume = "backup" + worldVolume = "world" + backupVolume = "backup" + felisBinaryPath = "/usr/local/bin/felis" ) // JobParams are the rendered inputs to a restore Job, derived from a server + @@ -78,10 +79,10 @@ func restoreLabels(p JobParams) map[string]string { // - activeDeadlineSeconds + backoffLimit=0 so a wedged or malicious archive // cannot loop or run forever; ttlSecondsAfterFinished GCs the finished Job. // -// The container runs `felis restore` (cmd/felis), which extracts the archive at -// BackupRef from the backup mount into the world mount. BackupRef is an absolute -// path, so the backup PVC MUST be mounted at BackupRoot — the same path the -// reaper wrote it under — for the ref to resolve. +// The container runs `/usr/local/bin/felis restore` (cmd/felis), which extracts +// the archive at BackupRef from the backup mount into the world mount. BackupRef +// is an absolute path, so the backup PVC MUST be mounted at BackupRoot — the +// same path the reaper wrote it under — for the ref to resolve. func RestoreJob(p JobParams) (*batchv1.Job, error) { if p.Image == "" { return nil, fmt.Errorf("restore: image is empty") @@ -105,7 +106,7 @@ func RestoreJob(p JobParams) (*batchv1.Job, error) { container := corev1.Container{ Name: "restore", Image: p.Image, - Command: []string{"felis", "restore"}, + Command: []string{felisBinaryPath, "restore"}, Args: []string{ "--server", p.Server, "--ref", p.BackupRef, diff --git a/internal/restore/jobspec_test.go b/internal/restore/jobspec_test.go index 649721f..af79a04 100644 --- a/internal/restore/jobspec_test.go +++ b/internal/restore/jobspec_test.go @@ -197,8 +197,9 @@ func TestRestoreJobContainerIsHardened(t *testing.T) { } } -// The container must invoke `felis restore` with the archive parameters as -// plain flags — and crucially the world PVC name the operator/reaper agree on. +// The container must invoke the felis restore binary by absolute path with the +// archive parameters as plain flags — and crucially the world PVC name the +// operator/reaper agree on. func TestRestoreJobInvokesFelisRestoreWithParams(t *testing.T) { p := sampleJobParams() job, err := RestoreJob(p) @@ -206,8 +207,8 @@ func TestRestoreJobInvokesFelisRestoreWithParams(t *testing.T) { t.Fatalf("RestoreJob: %v", err) } c := singleContainer(t, job) - if len(c.Command) < 2 || c.Command[0] != "felis" || c.Command[1] != "restore" { - t.Errorf("command = %v, want [felis restore ...]", c.Command) + if len(c.Command) < 2 || c.Command[0] != felisBinaryPath || c.Command[1] != "restore" { + t.Errorf("command = %v, want [%s restore ...]", c.Command, felisBinaryPath) } if !argPairPresent(c.Args, "--server", p.Server) { t.Errorf("args must carry --server %q, got %v", p.Server, c.Args)