refactor(deploy): improved TUI walkthrough

This commit is contained in:
Lemon-miaow committed 2026-06-30 02:25:18 +08:00
1 parent e5f1682898
commit 28c3eee361
20 files changed
+1580 -935

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
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)
}