refactor(deploy)!: TUI

This commit is contained in:
Lemon-miaow committed 2026-06-29 20:17:17 +08:00
1 parent 318a724f98
commit e5f1682898
37 files changed
+3622 -1303

No files matched your search

+575
View File
@@ -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{}
}
}