refactor(deploy): improved cloudflare walkthrough
This commit is contained in:
12 files changed
+869
-394
No files matched your search
+333
-329
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,34 +13,36 @@ import (
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
// edgeModel publishes the panel via Cloudflare Tunnel + Access. It shares the
|
||||
// wizard's chrome with every other connection screen: no banner of its own (the
|
||||
// root paints the step rail), one huh form for input (the two credential/host
|
||||
// groups), and the same locked, clear-screen styling as Local and Reverse-proxy.
|
||||
// The intro/working/done/error states stay custom — they show status and actions
|
||||
// rather than collect input — but they use the shared widgets so nothing reads
|
||||
// as a separate popup.
|
||||
type egStep int
|
||||
|
||||
const (
|
||||
egIntro egStep = iota
|
||||
egAuth
|
||||
egConfig
|
||||
egForm
|
||||
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
|
||||
result *cfsetup.Result
|
||||
progress []string
|
||||
err error
|
||||
}
|
||||
|
||||
type edgeModel struct {
|
||||
@@ -49,39 +52,50 @@ type edgeModel struct {
|
||||
adminHostname string
|
||||
panelHostname string
|
||||
|
||||
// cloudflared detection
|
||||
// cloudflared detection (intro gate)
|
||||
cloudflaredPath string
|
||||
certExists bool
|
||||
installing bool
|
||||
loginNote string
|
||||
|
||||
// auth step inputs
|
||||
authInputs []textinput.Model
|
||||
authFocus int
|
||||
authErr string
|
||||
// form-bound inputs (one huh form, two groups: credentials + hosts)
|
||||
form *huh.Form
|
||||
authToken string
|
||||
authAccount string
|
||||
|
||||
// config step inputs
|
||||
cfgInputs []textinput.Model
|
||||
cfgFocus int
|
||||
cfgErr string
|
||||
identity string
|
||||
panelHost string
|
||||
adminHost string
|
||||
tunnelName string
|
||||
cfgPath string
|
||||
|
||||
// working / result
|
||||
sp spinner.Model
|
||||
working string
|
||||
lastErr error
|
||||
prog []string
|
||||
result *cfsetup.Result
|
||||
panelSet string
|
||||
adminSet string
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newEdgeModel(rootDomain, adminHost, panelHost string) *edgeModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = tuiLabel
|
||||
|
||||
m := &edgeModel{
|
||||
step: egIntro,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHost,
|
||||
panelHostname: panelHost,
|
||||
sp: sp,
|
||||
// Prefill the host/identity fields so the common case is enter-through.
|
||||
panelHost: defaultPanelHostname(rootDomain, panelHost),
|
||||
adminHost: defaultAdminHostname(rootDomain, adminHost),
|
||||
tunnelName: defaultTunnelName,
|
||||
cfgPath: defaultTunnelConfigPath,
|
||||
}
|
||||
m.detectCloudflared()
|
||||
return m
|
||||
@@ -93,8 +107,134 @@ func (m *edgeModel) detectCloudflared() {
|
||||
m.certExists = pre.CertExists
|
||||
}
|
||||
|
||||
// build assembles the single two-group form. Group 1 collects the API
|
||||
// credentials; group 2 the hostnames and who Access admits. huh owns the
|
||||
// inter-group navigation natively — enter/tab advances (and runs that group's
|
||||
// validators first), shift+tab from the top of group 2 steps back to group 1,
|
||||
// and esc aborts the whole form, which we treat as "back to the intro". This is
|
||||
// why edge no longer hand-manages two separate input screens.
|
||||
func (m *edgeModel) build() *huh.Form {
|
||||
return m.sized(newFelisForm(
|
||||
huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Cloudflare credentials").
|
||||
Description("Create a scoped Bearer token (Account › Access: Edit) at:\n"+cloudflareAccessTokenTemplateURL),
|
||||
huh.NewInput().
|
||||
Title("API token").
|
||||
Description("starts cfat_… — not your Global API Key").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
CharLimit(200).
|
||||
Value(&m.authToken).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("a Cloudflare API token is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Account ID").
|
||||
Description("32-char hex from the dashboard URL: dash.cloudflare.com/<this>").
|
||||
CharLimit(64).
|
||||
Value(&m.authAccount).
|
||||
Validate(validateAccountID),
|
||||
).Title("Step 1 · Credentials"),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Admit").
|
||||
Description("Who Access lets in: your email, or @your-domain").
|
||||
CharLimit(254).
|
||||
Value(&m.identity).
|
||||
Validate(validateAccessIdentity),
|
||||
huh.NewInput().
|
||||
Title("Player console hostname").
|
||||
CharLimit(253).
|
||||
Value(&m.panelHost).
|
||||
Validate(func(s string) error {
|
||||
return validateEdgeHostname("player console", normalizeEdgeHostname(s), false)
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Admin console hostname").
|
||||
Description("fronted by Cloudflare Access").
|
||||
CharLimit(253).
|
||||
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
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Tunnel name").
|
||||
CharLimit(64).
|
||||
Value(&m.tunnelName),
|
||||
huh.NewInput().
|
||||
Title("Tunnel config path").
|
||||
CharLimit(256).
|
||||
Value(&m.cfgPath),
|
||||
).Title("Step 2 · Hostnames & identity"),
|
||||
))
|
||||
}
|
||||
|
||||
// validateAccountID accepts the 32-char hex account id and gives a targeted nudge
|
||||
// when the operator pastes the API token into the wrong field.
|
||||
func validateAccountID(s string) error {
|
||||
a := strings.TrimSpace(s)
|
||||
if a == "" {
|
||||
return errors.New("the Cloudflare account ID is required")
|
||||
}
|
||||
if !isHex32(a) {
|
||||
if strings.HasPrefix(a, "cfat_") {
|
||||
return errors.New("that looks like an API token — the Account ID is a 32-char hex string")
|
||||
}
|
||||
return errors.New("the Account ID must be 32 hex characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAccessIdentity accepts an email or an @domain wildcard.
|
||||
func validateAccessIdentity(s string) error {
|
||||
id := strings.TrimSpace(s)
|
||||
if id == "" {
|
||||
return errors.New("enter who Access should admit")
|
||||
}
|
||||
if strings.HasPrefix(id, "@") && strings.TrimPrefix(id, "@") == "" {
|
||||
return errors.New("enter a domain after the @, e.g. @your-domain")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *edgeModel) sized(f *huh.Form) *huh.Form {
|
||||
if m.width > 0 {
|
||||
return f.WithWidth(m.width).WithHeight(m.height)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *edgeModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
m.form = m.form.WithWidth(w).WithHeight(h)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) Init() tea.Cmd { return nil }
|
||||
|
||||
// arrowNavOK yields ←/→ to the root's step rail only when no text field is
|
||||
// focused: the intro and the terminal states take single-key actions, so the
|
||||
// horizontal arrows are free, but while the form is up they belong to the cursor.
|
||||
func (m *edgeModel) arrowNavOK() bool {
|
||||
switch m.step {
|
||||
case egForm, egWorking:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case egLoginDoneMsg:
|
||||
@@ -118,18 +258,9 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
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 = ""
|
||||
m.prog = msg.progress
|
||||
if msg.err != nil {
|
||||
m.step = egError
|
||||
m.lastErr = msg.err
|
||||
@@ -137,161 +268,177 @@ func (m *edgeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.step = egDone
|
||||
m.result = msg.result
|
||||
if msg.result != nil {
|
||||
m.prog = msg.result.Progress
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.step == egWorking {
|
||||
var cmd tea.Cmd
|
||||
m.sp, cmd = m.sp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
return m.handleIntroKey(msg)
|
||||
case egWorking:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
case egDone:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
return m, nil
|
||||
case egError:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
// Re-run with the same (still-valid) inputs. Setup is idempotent,
|
||||
// so a retry recovers cleanly from a transient failure.
|
||||
return m, m.startSetup()
|
||||
case "esc":
|
||||
m.step = egForm
|
||||
m.lastErr, m.prog = nil, nil
|
||||
m.form = m.build()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
return m, nil
|
||||
case egForm:
|
||||
// Intercept the exits before huh sees them: huh collapses esc and ctrl+c
|
||||
// into a single StateAborted, so we can't tell them apart afterward.
|
||||
// esc steps back to the intro/status screen; ctrl+c quits, matching every
|
||||
// sibling screen. Any other key falls through to the form below.
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
m.step = egIntro
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.step == egForm && 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:
|
||||
return m, m.startSetup()
|
||||
case huh.StateAborted:
|
||||
// esc on the form steps back to the intro/status screen.
|
||||
m.step = egIntro
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
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 the panel via Cloudflare Tunnel + Access — no open ports, TLS and admin identity handled by Cloudflare. Press esc to pick a different method.") + "\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")
|
||||
}
|
||||
case egForm:
|
||||
if m.form == nil {
|
||||
return ""
|
||||
}
|
||||
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"))
|
||||
return m.form.View()
|
||||
|
||||
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")
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Configuring Cloudflare Tunnel + Access edge…"
|
||||
}
|
||||
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\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"))
|
||||
return m.doneView()
|
||||
|
||||
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")
|
||||
return m.errorView()
|
||||
|
||||
default:
|
||||
return m.introView()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *edgeModel) introView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports; TLS and the admin identity check are handled at Cloudflare's edge.") + "\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.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "retry", "esc", "back"))
|
||||
}
|
||||
if m.installing {
|
||||
b.WriteString(" " + tuiIconSpin + " " + tuiHint.Render("downloading cloudflared…") + "\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) + tuiHint.Render(" (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"))
|
||||
}
|
||||
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, goBack()
|
||||
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
|
||||
func (m *edgeModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiOK.Render("✓ Cloudflare edge configured.") + "\n\n")
|
||||
var card strings.Builder
|
||||
if m.result != nil {
|
||||
card.WriteString(tuiLabel.Render("access_jwt_aud ") + m.result.AccessAud + "\n")
|
||||
if len(m.result.RoutedHostnames) > 0 {
|
||||
card.WriteString(tuiLabel.Render("routed ") + strings.Join(m.result.RoutedHostnames, ", ") + "\n")
|
||||
}
|
||||
if m.result.ConfigPath != "" {
|
||||
card.WriteString(tuiLabel.Render("tunnel config ") + m.result.ConfigPath + "\n")
|
||||
}
|
||||
default:
|
||||
}
|
||||
return m, nil
|
||||
b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n")
|
||||
b.WriteString(tuiHint.Render("ℹ Felis config, Kubernetes Secret, API rollout and the cloudflared service were all updated.") + "\n")
|
||||
b.WriteString("\n" + tuiAction("enter/esc", "continue"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *edgeModel) errorView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiErr.Render("✗ Edge setup failed.") + "\n\n")
|
||||
if len(m.prog) > 0 {
|
||||
b.WriteString(tuiHint.Render("Completed before the failure:") + "\n")
|
||||
for _, s := range m.prog {
|
||||
b.WriteString(" " + tuiOK.Render("✓") + " " + tuiHint.Render(s) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiAction("enter", "retry", "esc", "edit"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
@@ -305,184 +452,41 @@ func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case "l", "L":
|
||||
if m.cloudflaredPath != "" && !m.certExists {
|
||||
nm, cmd := m.startLogin()
|
||||
return nm, cmd
|
||||
return m.startLogin()
|
||||
}
|
||||
case "enter":
|
||||
if m.cloudflaredPath != "" && m.certExists {
|
||||
m.step = egAuth
|
||||
return m, m.enterAuth()
|
||||
m.step = egForm
|
||||
m.form = m.build()
|
||||
return m, m.form.Init()
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
// startSetup reads the form-bound fields (already validated by huh), normalizes
|
||||
// the hostnames, applies the tunnel/config defaults, and kicks off the live
|
||||
// Cloudflare run. It is shared by the form-completed path and the error retry, so
|
||||
// a retry re-runs against the same inputs without a re-entry.
|
||||
func (m *edgeModel) startSetup() tea.Cmd {
|
||||
m.panelHost = normalizeEdgeHostname(m.panelHost)
|
||||
m.adminHost = normalizeEdgeHostname(m.adminHost)
|
||||
identity := strings.TrimSpace(m.identity)
|
||||
tunnel := strings.TrimSpace(m.tunnelName)
|
||||
if tunnel == "" {
|
||||
tunnel = defaultTunnelName
|
||||
}
|
||||
cfgPath := strings.TrimSpace(m.cfgPath)
|
||||
if cfgPath == "" {
|
||||
cfgPath = defaultTunnelConfigPath
|
||||
}
|
||||
m.tunnelName, m.cfgPath = tunnel, cfgPath
|
||||
|
||||
m.panelSet = panelHost
|
||||
m.adminSet = adminHost
|
||||
m.cfgErr = ""
|
||||
m.panelSet, m.adminSet = m.panelHost, m.adminHost
|
||||
m.prog = nil
|
||||
m.lastErr = nil
|
||||
m.step = egWorking
|
||||
m.working = "Starting…"
|
||||
m.working = "Configuring Cloudflare Tunnel + Access edge…"
|
||||
|
||||
var id cfsetup.AccessIdentity
|
||||
if strings.HasPrefix(identity, "@") {
|
||||
@@ -497,8 +501,8 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
AccountID: m.authAccount,
|
||||
}
|
||||
p := cfsetup.Params{
|
||||
PanelHostname: panelHost,
|
||||
AdminHostname: adminHost,
|
||||
PanelHostname: m.panelHost,
|
||||
AdminHostname: m.adminHost,
|
||||
PanelOrigin: localPanelOrigin(),
|
||||
TunnelName: tunnel,
|
||||
ConfigPath: cfgPath,
|
||||
@@ -506,7 +510,7 @@ func (m *edgeModel) submitConfig() (tea.Model, tea.Cmd) {
|
||||
Pre: cfsetup.DetectPreconditions(m.authToken),
|
||||
}
|
||||
|
||||
return m, m.runEdgeSetup(runner, p)
|
||||
return tea.Batch(m.sp.Tick, m.runEdgeSetup(runner, p))
|
||||
}
|
||||
|
||||
func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cmd {
|
||||
@@ -517,15 +521,15 @@ func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cm
|
||||
return func() tea.Msg {
|
||||
result, err := cfsetup.Setup(context.Background(), runner, p)
|
||||
if err != nil {
|
||||
return egSetupDoneMsg{err: err}
|
||||
return egSetupDoneMsg{err: err, progress: progress}
|
||||
}
|
||||
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}
|
||||
return egSetupDoneMsg{err: err, progress: progress}
|
||||
}
|
||||
progress = append(progress, "Updated Felis config and started cloudflared")
|
||||
result.Progress = progress
|
||||
return egSetupDoneMsg{result: result}
|
||||
return egSetupDoneMsg{result: result, progress: progress}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user