refactor(deploy): improved TUI walkthrough
This commit is contained in:
20 files changed
+1580
-935
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user