Unverified Commit 28c3eee3 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

refactor(deploy): improved TUI walkthrough

parent e5f16828
Loading
Loading
Loading
Loading
+14 −8
Changes for cmd/felis/breakglass.go: 14 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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
+12 −5
Changes for cmd/felis/setup.go: 12 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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
@@ -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.")
@@ -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, ", "))
			}
@@ -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
}
+1 −1
Changes for cmd/felis/tui_bootstrap.go: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -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:
+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.go

deleted100644 → 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