refactor(deploy): improved cloudflare walkthrough

This commit is contained in:
Lemon-miaow committed 2026-06-30 04:20:36 +08:00
1 parent a94b0015c4
commit 346ec68e51
12 files changed
+869 -394

No files matched your search

+333 -329
View File
@@ -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}
}
}