Loading cmd/felis/breakglass.go +14 −8 Changes for cmd/felis/breakglass.go: 14 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -125,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, 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, cfg.Auth.AccessJWTAud, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 Loading Loading @@ -411,7 +411,13 @@ type breakGlassResult struct { adminHostname string panelURL string // optional Cloudflare edge outcome (independent of provisioned) // connection outcome (independent of provisioned) connectMethod connectMethod connectConfigured bool panelHostname string reverseProxyGuide string // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare) edgeConfigured bool edgeAud string edgeRoutedHosts []string Loading @@ -438,16 +444,16 @@ const ( cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) 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) func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeBreakGlass) } 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) func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeSetup) } 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) func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode) final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err Loading cmd/felis/setup.go +12 −5 Changes for cmd/felis/setup.go: 12 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -84,7 +84,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } defer setup.drv.Close() 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) res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, accountableOSUser(), setup.adminExists) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 Loading @@ -94,9 +94,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { panelURL = localPanelURL(setup.cfg.Server.RootDomain) } if !res.provisioned && !res.edgeConfigured { if !res.provisioned && !res.connectConfigured { if bootstrapped { fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.") fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection 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.") Loading Loading @@ -127,8 +127,10 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } } if res.edgeConfigured { fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n") if res.connectConfigured { switch res.connectMethod { case connectCloudflare: fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access configured.\n") if len(res.edgeRoutedHosts) > 0 { fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", ")) } Loading @@ -136,6 +138,11 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath) } fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.") case connectReverseProxy: fmt.Fprintf(stdout, "\nfelis setup: reverse-proxy front configured. Point your proxy at the origin:\n\n") fmt.Fprintln(stdout, res.reverseProxyGuide) fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } } return 0 } Loading cmd/felis/tui_bootstrap.go +1 −1 Changes for cmd/felis/tui_bootstrap.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -97,7 +97,7 @@ func (m *hostBootstrapModel) View() string { 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(tuiInfo("Continue to create the Owner account and choose how the panel is reached.") + "\n") b.WriteString("\n" + tuiSeparator() + "\n") b.WriteString(tuiAction("enter", "continue", "esc", "continue")) case hostBootstrapError: Loading cmd/felis/tui_connect.go 0 → 100644 +349 −0 Changes for cmd/felis/tui_connect.go: 349 added lines, 0 removed lines. Original line number Diff line number Diff line package main import ( "context" "errors" "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" ) // connectChooserModel presents the ways to reach the panel as peer choices. // None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a // turnkey integration, and "Reverse proxy" just records hostnames and hands the // operator a copy-paste guide. The admin console is gated by the Owner's // local-password session regardless; Cloudflare Access is an *additional* layer. type connectChooserModel struct { rootDomain string adminHost string panelHost string form *huh.Form choice connectMethod width, height int } func newConnectChooserModel(rootDomain, adminHost, panelHost string) *connectChooserModel { m := &connectChooserModel{rootDomain: rootDomain, adminHost: adminHost, panelHost: panelHost} m.form = m.build() return m } func (m *connectChooserModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewSelect[connectMethod](). Title("How should people reach the panel?"). Description("You can change this later in the panel."). Value(&m.choice). Options( huh.NewOption("Local only · nothing installed", connectLocal), huh.NewOption("Cloudflare Tunnel + Access · no open ports", connectCloudflare), huh.NewOption("Reverse proxy (bring your own) · guided", connectReverseProxy), ), huh.NewNote(). Title("⚠ Security"). Description( "With Local or reverse proxy, anyone who can reach the admin hostname can attempt "+ "login — the admin console is gated by your Owner password alone. "+ "Cloudflare Access adds an edge check in front of it."), ))) } func (m *connectChooserModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *connectChooserModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *connectChooserModel) Init() tea.Cmd { return m.form.Init() } func (m *connectChooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "ctrl+c": return m, tea.Quit case "esc": // Skipping is choosing local — the operator can change this later. return m, m.chooseLocal() } } form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: return m.onComplete() case huh.StateAborted: return m, tea.Quit } return m, cmd } func (m *connectChooserModel) onComplete() (tea.Model, tea.Cmd) { switch m.choice { case connectCloudflare: return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil case connectReverseProxy: return newReverseProxyModel(m.rootDomain, m.adminHost, m.panelHost), nil default: return m, m.chooseLocal() } } func (m *connectChooserModel) chooseLocal() tea.Cmd { panel := defaultPanelHostname(m.rootDomain, m.panelHost) admin := defaultAdminHostname(m.rootDomain, m.adminHost) return func() tea.Msg { return connectResultMsg{method: connectLocal, panelHostname: panel, adminHostname: admin} } } func (m *connectChooserModel) View() string { return m.form.View() } // ---- Reverse proxy: collect hostnames, record them, render a guide ---- type rpStep int const ( rpForm rpStep = iota rpWorking rpGuide rpError ) type rpApplyMsg struct{ err error } type reverseProxyModel struct { rootDomain string step rpStep form *huh.Form sp spinner.Model err error panelHost string adminHost string width, height int } func newReverseProxyModel(rootDomain, adminHost, panelHost string) *reverseProxyModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel m := &reverseProxyModel{ rootDomain: rootDomain, step: rpForm, sp: sp, panelHost: defaultPanelHostname(rootDomain, panelHost), adminHost: defaultAdminHostname(rootDomain, adminHost), } m.form = m.build() return m } func (m *reverseProxyModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewNote(). Title("Reverse proxy"). Description("Enter the public hostnames your reverse proxy will serve. We record them and show you the config — you point the proxy at the origin."), huh.NewInput(). Title("Player console hostname"). Value(&m.panelHost). Validate(func(s string) error { return validateEdgeHostname("player console", normalizeEdgeHostname(s), false) }), huh.NewInput(). Title("Admin console hostname"). Value(&m.adminHost). Validate(func(s string) error { admin := normalizeEdgeHostname(s) if err := validateEdgeHostname("admin console", admin, true); err != nil { return err } if panel := normalizeEdgeHostname(m.panelHost); panel != "" && strings.EqualFold(panel, admin) { return errors.New("player and admin console hostnames must be different") } return nil }), ))) } func (m *reverseProxyModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *reverseProxyModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *reverseProxyModel) Init() tea.Cmd { return m.form.Init() } func (m *reverseProxyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case rpApplyMsg: if msg.err != nil { m.step, m.err = rpError, msg.err return m, nil } m.step = rpGuide return m, nil case spinner.TickMsg: if m.step == rpWorking { var cmd tea.Cmd m.sp, cmd = m.sp.Update(msg) return m, cmd } return m, nil case tea.KeyMsg: switch m.step { case rpForm: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": return m, goBack() } case rpGuide: switch msg.String() { case "ctrl+c", "esc", "enter": return m, func() tea.Msg { return connectResultMsg{ method: connectReverseProxy, panelHostname: m.panelHost, adminHostname: m.adminHost, guide: reverseProxyGuide(m.panelHost, m.adminHost), } } } return m, nil case rpError: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": m.step, m.err = rpForm, nil m.form = m.build() return m, m.form.Init() case "enter": return m, m.apply() } return m, nil case rpWorking: if msg.String() == "ctrl+c" { return m, tea.Quit } return m, nil } } if m.step == rpForm && m.form != nil { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: m.panelHost = normalizeEdgeHostname(m.panelHost) m.adminHost = normalizeEdgeHostname(m.adminHost) m.step = rpWorking return m, tea.Batch(m.sp.Tick, m.apply()) case huh.StateAborted: return m, goBack() } return m, cmd } return m, nil } func (m *reverseProxyModel) apply() tea.Cmd { panel, admin := m.panelHost, m.adminHost return func() tea.Msg { return rpApplyMsg{err: applyReverseProxy(context.Background(), panel, admin)} } } func (m *reverseProxyModel) View() string { switch m.step { case rpWorking: return " " + m.sp.View() + " " + tuiHint.Render("Recording hostnames and rolling the API…") + "\n" case rpGuide: var b strings.Builder b.WriteString(tuiSuccessBanner("Hostnames recorded. Now point your reverse proxy at the origin.") + "\n\n") b.WriteString(reverseProxyGuideView(m.panelHost, m.adminHost)) b.WriteString("\n" + tuiAction("enter", "done")) return b.String() case rpError: var b strings.Builder b.WriteString(tuiErrorBanner("Could not record hostnames.") + "\n\n") if m.err != nil { b.WriteString(tuiHint.Render(m.err.Error()) + "\n") } b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) return b.String() default: if m.form == nil { return "" } return m.form.View() } } // reverseProxyGuideView renders the operator-facing guide with styled snippets. func reverseProxyGuideView(panelHost, adminHost string) string { var b strings.Builder origin := localPanelOrigin() b.WriteString(tuiInfo("Origin: "+origin+" · self-signed cert (skip upstream TLS verify) · forward the Host header") + "\n\n") b.WriteString(tuiGuideBlock("Caddyfile", caddySnippet(adminHost, origin))) if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { b.WriteString("\n" + tuiGuideBlock("", caddySnippet(panelHost, origin))) } return b.String() } // reverseProxyGuide returns the same guidance as plain text for the post-TUI // stdout summary (e.g. when piped to a log). func reverseProxyGuide(panelHost, adminHost string) string { origin := localPanelOrigin() var b strings.Builder fmt.Fprintf(&b, "Origin: %s (self-signed — skip upstream TLS verification; forward the Host header)\n\n", origin) b.WriteString("Caddy example:\n") b.WriteString(caddySnippet(adminHost, origin)) if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { b.WriteString("\n") b.WriteString(caddySnippet(panelHost, origin)) } return b.String() } func caddySnippet(host, origin string) string { if host == "" { host = "your-hostname" } return fmt.Sprintf(`%s { reverse_proxy %s { transport http { tls_insecure_skip_verify } header_up Host {host} } }`, host, origin) } cmd/felis/tui_dashboard.godeleted 100644 → 0 +0 −139 Changes for cmd/felis/tui_dashboard.go: 0 added lines, 139 removed lines. Original line number Diff line number Diff line 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() } Loading
cmd/felis/breakglass.go +14 −8 Changes for cmd/felis/breakglass.go: 14 added lines, 8 removed lines. Original line number Diff line number Diff line Loading @@ -125,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { return 1 } res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, 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, cfg.Auth.AccessJWTAud, accountableOSUser(), adminExists) if err != nil { fmt.Fprintf(stderr, "felis breakGlass: %v\n", err) return 1 Loading Loading @@ -411,7 +411,13 @@ type breakGlassResult struct { adminHostname string panelURL string // optional Cloudflare edge outcome (independent of provisioned) // connection outcome (independent of provisioned) connectMethod connectMethod connectConfigured bool panelHostname string reverseProxyGuide string // Cloudflare-specific edge detail (set only when connectMethod is Cloudflare) edgeConfigured bool edgeAud string edgeRoutedHosts []string Loading @@ -438,16 +444,16 @@ const ( cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/" ) 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) func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeBreakGlass) } 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) func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) { return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeSetup) } 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) func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) { rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode) final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err Loading
cmd/felis/setup.go +12 −5 Changes for cmd/felis/setup.go: 12 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -84,7 +84,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } defer setup.drv.Close() 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) res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, accountableOSUser(), setup.adminExists) if err != nil { fmt.Fprintf(stderr, "felis setup: %v\n", err) return 1 Loading @@ -94,9 +94,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { panelURL = localPanelURL(setup.cfg.Server.RootDomain) } if !res.provisioned && !res.edgeConfigured { if !res.provisioned && !res.connectConfigured { if bootstrapped { fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.") fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection 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.") Loading Loading @@ -127,8 +127,10 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } } if res.edgeConfigured { fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n") if res.connectConfigured { switch res.connectMethod { case connectCloudflare: fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access configured.\n") if len(res.edgeRoutedHosts) > 0 { fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", ")) } Loading @@ -136,6 +138,11 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath) } fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.") case connectReverseProxy: fmt.Fprintf(stdout, "\nfelis setup: reverse-proxy front configured. Point your proxy at the origin:\n\n") fmt.Fprintln(stdout, res.reverseProxyGuide) fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.") } } return 0 } Loading
cmd/felis/tui_bootstrap.go +1 −1 Changes for cmd/felis/tui_bootstrap.go: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -97,7 +97,7 @@ func (m *hostBootstrapModel) View() string { 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(tuiInfo("Continue to create the Owner account and choose how the panel is reached.") + "\n") b.WriteString("\n" + tuiSeparator() + "\n") b.WriteString(tuiAction("enter", "continue", "esc", "continue")) case hostBootstrapError: Loading
cmd/felis/tui_connect.go 0 → 100644 +349 −0 Changes for cmd/felis/tui_connect.go: 349 added lines, 0 removed lines. Original line number Diff line number Diff line package main import ( "context" "errors" "fmt" "strings" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" ) // connectChooserModel presents the ways to reach the panel as peer choices. // None is privileged: "Local" installs nothing, "Cloudflare Tunnel" is a // turnkey integration, and "Reverse proxy" just records hostnames and hands the // operator a copy-paste guide. The admin console is gated by the Owner's // local-password session regardless; Cloudflare Access is an *additional* layer. type connectChooserModel struct { rootDomain string adminHost string panelHost string form *huh.Form choice connectMethod width, height int } func newConnectChooserModel(rootDomain, adminHost, panelHost string) *connectChooserModel { m := &connectChooserModel{rootDomain: rootDomain, adminHost: adminHost, panelHost: panelHost} m.form = m.build() return m } func (m *connectChooserModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewSelect[connectMethod](). Title("How should people reach the panel?"). Description("You can change this later in the panel."). Value(&m.choice). Options( huh.NewOption("Local only · nothing installed", connectLocal), huh.NewOption("Cloudflare Tunnel + Access · no open ports", connectCloudflare), huh.NewOption("Reverse proxy (bring your own) · guided", connectReverseProxy), ), huh.NewNote(). Title("⚠ Security"). Description( "With Local or reverse proxy, anyone who can reach the admin hostname can attempt "+ "login — the admin console is gated by your Owner password alone. "+ "Cloudflare Access adds an edge check in front of it."), ))) } func (m *connectChooserModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *connectChooserModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *connectChooserModel) Init() tea.Cmd { return m.form.Init() } func (m *connectChooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "ctrl+c": return m, tea.Quit case "esc": // Skipping is choosing local — the operator can change this later. return m, m.chooseLocal() } } form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: return m.onComplete() case huh.StateAborted: return m, tea.Quit } return m, cmd } func (m *connectChooserModel) onComplete() (tea.Model, tea.Cmd) { switch m.choice { case connectCloudflare: return newEdgeModel(m.rootDomain, m.adminHost, m.panelHost), nil case connectReverseProxy: return newReverseProxyModel(m.rootDomain, m.adminHost, m.panelHost), nil default: return m, m.chooseLocal() } } func (m *connectChooserModel) chooseLocal() tea.Cmd { panel := defaultPanelHostname(m.rootDomain, m.panelHost) admin := defaultAdminHostname(m.rootDomain, m.adminHost) return func() tea.Msg { return connectResultMsg{method: connectLocal, panelHostname: panel, adminHostname: admin} } } func (m *connectChooserModel) View() string { return m.form.View() } // ---- Reverse proxy: collect hostnames, record them, render a guide ---- type rpStep int const ( rpForm rpStep = iota rpWorking rpGuide rpError ) type rpApplyMsg struct{ err error } type reverseProxyModel struct { rootDomain string step rpStep form *huh.Form sp spinner.Model err error panelHost string adminHost string width, height int } func newReverseProxyModel(rootDomain, adminHost, panelHost string) *reverseProxyModel { sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = tuiLabel m := &reverseProxyModel{ rootDomain: rootDomain, step: rpForm, sp: sp, panelHost: defaultPanelHostname(rootDomain, panelHost), adminHost: defaultAdminHostname(rootDomain, adminHost), } m.form = m.build() return m } func (m *reverseProxyModel) build() *huh.Form { return m.sized(newFelisForm(huh.NewGroup( huh.NewNote(). Title("Reverse proxy"). Description("Enter the public hostnames your reverse proxy will serve. We record them and show you the config — you point the proxy at the origin."), huh.NewInput(). Title("Player console hostname"). Value(&m.panelHost). Validate(func(s string) error { return validateEdgeHostname("player console", normalizeEdgeHostname(s), false) }), huh.NewInput(). Title("Admin console hostname"). Value(&m.adminHost). Validate(func(s string) error { admin := normalizeEdgeHostname(s) if err := validateEdgeHostname("admin console", admin, true); err != nil { return err } if panel := normalizeEdgeHostname(m.panelHost); panel != "" && strings.EqualFold(panel, admin) { return errors.New("player and admin console hostnames must be different") } return nil }), ))) } func (m *reverseProxyModel) sized(f *huh.Form) *huh.Form { if m.width > 0 { return f.WithWidth(m.width).WithHeight(m.height) } return f } func (m *reverseProxyModel) setSize(w, h int) { m.width, m.height = w, h if m.form != nil { m.form = m.form.WithWidth(w).WithHeight(h) } } func (m *reverseProxyModel) Init() tea.Cmd { return m.form.Init() } func (m *reverseProxyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case rpApplyMsg: if msg.err != nil { m.step, m.err = rpError, msg.err return m, nil } m.step = rpGuide return m, nil case spinner.TickMsg: if m.step == rpWorking { var cmd tea.Cmd m.sp, cmd = m.sp.Update(msg) return m, cmd } return m, nil case tea.KeyMsg: switch m.step { case rpForm: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": return m, goBack() } case rpGuide: switch msg.String() { case "ctrl+c", "esc", "enter": return m, func() tea.Msg { return connectResultMsg{ method: connectReverseProxy, panelHostname: m.panelHost, adminHostname: m.adminHost, guide: reverseProxyGuide(m.panelHost, m.adminHost), } } } return m, nil case rpError: switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": m.step, m.err = rpForm, nil m.form = m.build() return m, m.form.Init() case "enter": return m, m.apply() } return m, nil case rpWorking: if msg.String() == "ctrl+c" { return m, tea.Quit } return m, nil } } if m.step == rpForm && m.form != nil { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateCompleted: m.panelHost = normalizeEdgeHostname(m.panelHost) m.adminHost = normalizeEdgeHostname(m.adminHost) m.step = rpWorking return m, tea.Batch(m.sp.Tick, m.apply()) case huh.StateAborted: return m, goBack() } return m, cmd } return m, nil } func (m *reverseProxyModel) apply() tea.Cmd { panel, admin := m.panelHost, m.adminHost return func() tea.Msg { return rpApplyMsg{err: applyReverseProxy(context.Background(), panel, admin)} } } func (m *reverseProxyModel) View() string { switch m.step { case rpWorking: return " " + m.sp.View() + " " + tuiHint.Render("Recording hostnames and rolling the API…") + "\n" case rpGuide: var b strings.Builder b.WriteString(tuiSuccessBanner("Hostnames recorded. Now point your reverse proxy at the origin.") + "\n\n") b.WriteString(reverseProxyGuideView(m.panelHost, m.adminHost)) b.WriteString("\n" + tuiAction("enter", "done")) return b.String() case rpError: var b strings.Builder b.WriteString(tuiErrorBanner("Could not record hostnames.") + "\n\n") if m.err != nil { b.WriteString(tuiHint.Render(m.err.Error()) + "\n") } b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit")) return b.String() default: if m.form == nil { return "" } return m.form.View() } } // reverseProxyGuideView renders the operator-facing guide with styled snippets. func reverseProxyGuideView(panelHost, adminHost string) string { var b strings.Builder origin := localPanelOrigin() b.WriteString(tuiInfo("Origin: "+origin+" · self-signed cert (skip upstream TLS verify) · forward the Host header") + "\n\n") b.WriteString(tuiGuideBlock("Caddyfile", caddySnippet(adminHost, origin))) if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { b.WriteString("\n" + tuiGuideBlock("", caddySnippet(panelHost, origin))) } return b.String() } // reverseProxyGuide returns the same guidance as plain text for the post-TUI // stdout summary (e.g. when piped to a log). func reverseProxyGuide(panelHost, adminHost string) string { origin := localPanelOrigin() var b strings.Builder fmt.Fprintf(&b, "Origin: %s (self-signed — skip upstream TLS verification; forward the Host header)\n\n", origin) b.WriteString("Caddy example:\n") b.WriteString(caddySnippet(adminHost, origin)) if panelHost != "" && !strings.EqualFold(panelHost, adminHost) { b.WriteString("\n") b.WriteString(caddySnippet(panelHost, origin)) } return b.String() } func caddySnippet(host, origin string) string { if host == "" { host = "your-hostname" } return fmt.Sprintf(`%s { reverse_proxy %s { transport http { tls_insecure_skip_verify } header_up Host {host} } }`, host, origin) }
cmd/felis/tui_dashboard.godeleted 100644 → 0 +0 −139 Changes for cmd/felis/tui_dashboard.go: 0 added lines, 139 removed lines. Original line number Diff line number Diff line 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() }