refactor(deploy)!: TUI

This commit is contained in:
Lemon-miaow committed 2026-06-29 20:17:17 +08:00
1 parent 318a724f98
commit e5f1682898
37 files changed
+3610 -1291

No files matched your search

+7 -13
View File
@@ -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=<node-ip>.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.
+20 -11
View File
@@ -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"]
+20
View File
@@ -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")
}
+23 -2
View File
@@ -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() }()
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 {
+25
View File
@@ -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
}
+24 -911
View File
@@ -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("[email protected] 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 ""
}
-240
View File
@@ -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("[email protected]")
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")
}
})
}
+6
View File
@@ -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,
+3 -3
View File
@@ -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:
+3 -1
View File
@@ -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 <command> -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
+199 -35
View File
@@ -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)
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
}
if rootDomain != "" {
return "https://op.console." + rootDomain
type setupOpenError struct {
stage string
err error
}
return ""
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}
}
drv, err := store.Open(ctx, cfg.Database.URL)
if err != nil {
return nil, &setupOpenError{stage: "open database", err: err}
}
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
}
+108
View File
@@ -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}
}
+88
View File
@@ -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")
}
}
+140
View File
@@ -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
}
+139
View File
@@ -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()
}
+575
View File
@@ -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/<this-part>") + "\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("[email protected] 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{}
}
}
+152
View File
@@ -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
}
+142
View File
@@ -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
}
+380
View File
@@ -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)
}
+260
View File
@@ -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
}
+265
View File
@@ -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)}
}
}
+111
View File
@@ -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 " "
}
}
+124
View File
@@ -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))
}
+449 -28
View File
@@ -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: <node-ip>.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,13 +150,162 @@ 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
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 <url> | sudo bash)"
fi
detect_os() {
[ -r /etc/os-release ] || die "cannot read /etc/os-release; unsupported host"
@@ -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 <node-ip>: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" <<EOF
DB_PASSWORD=${DB_PASSWORD}
SERVICE_TOKEN=${SERVICE_TOKEN}
SESSION_SECRET=${SESSION_SECRET}
EOF
)
chmod 0600 "$SECRETS_ENV"
}
ensure_panel_tls_cert() {
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
if [ -s "$PANEL_TLS_CERT" ] && [ -s "$PANEL_TLS_KEY" ]; then
ok "panel TLS certificate already present"
return 0
fi
local cn conf
cn="op.console.${FELIS_ROOT_DOMAIN}"
conf="$(mktemp)"
remember_temp "$conf"
cat > "$conf" <<EOF
[req]
default_bits = 2048
distinguished_name = dn
x509_extensions = v3_req
prompt = no
[dn]
CN = ${cn}
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = op.console.${FELIS_ROOT_DOMAIN}
DNS.2 = console.${FELIS_ROOT_DOMAIN}
DNS.3 = localhost
IP.1 = 127.0.0.1
IP.2 = ${NODE_IP}
EOF
log "generating self-signed panel TLS certificate"
openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
-keyout "$PANEL_TLS_KEY" \
-out "$PANEL_TLS_CERT" \
-subj "/CN=${cn}" \
-config "$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" <<EOF
@@ -551,12 +895,20 @@ EOF
}
ensure_default_config() {
local target="${STATE_DIR}/felis.toml"
local target="${STATE_DIR}/felis.toml" backup
if [ -L "$target" ] && [ "$(readlink "$target")" = "${STATE_DIR}/felis.host.toml" ]; then
ok "default host config already points at ${STATE_DIR}/felis.host.toml"
return 0
fi
if [ -e "$target" ] || [ -L "$target" ]; then
if bootstrap_from_tui; then
backup="${target}.bak.$(date -u +%Y%m%d%H%M%S).$$"
warn "replacing existing ${target}; backup saved at ${backup}"
mv "$target" "$backup"
ln -s "${STATE_DIR}/felis.host.toml" "$target"
ok "default host config: ${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"
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
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
}
+10
View File
@@ -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")
+5
View File
@@ -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.<root_domain>.
// 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, "."))
}
+43 -10
View File
@@ -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"
@@ -255,6 +254,11 @@ type tunnelConfig struct {
type ingressRule struct {
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.<root_domain> (Player web)
AdminHostname string // op.console.<root_domain> (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
}
+27
View File
@@ -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)
}
}
+91
View File
@@ -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))
}
+50
View File
@@ -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)
}
}
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Felis Control Panel</title>
</head>
<body>
<div id="root">Felis panel assets were not built into this binary.</div>
</body>
</html>
+3 -5
View File
@@ -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
+7
View File
@@ -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
}
+40 -12
View File
@@ -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.<ns>.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,
+49 -11
View File
@@ -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 <cfg> --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 <cfg> --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)
+6 -5
View File
@@ -22,6 +22,7 @@ const (
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,
+5 -4
View File
@@ -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)