refactor(deploy)!: TUI
This commit is contained in:
37 files changed
+3610
-1291
No files matched your search
+7
-13
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
}
|
||||
@@ -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 " "
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user