Files
Felis/cmd/felis/tui_smtp.go

434 lines
14 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/platform"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"
)
// ---- Email (SMTP) relay: the post-install "configure email" screen ----
//
// Bootstrap deliberately has no SMTP (the Owner's address is recorded
// unverified and the passkey is the only pre-SMTP credential), so this screen
// is where a deployment gains real mail: email verification, email-OTP login
// and the op-login mailbox factor all start working once it applies. It is
// reached from the summary/status screen ("e"), mirroring "change storage".
// smtpInputs is the operator-entered relay coordinates. Only host/port/from/
// username reach felis.toml; the password goes into the felis-smtp Secret.
type smtpInputs struct {
host string
port string
from string
username string
password string
}
// reconfigureSMTPMsg is sent from the summary/status screen to open the email
// relay form — the supported way to configure or fix SMTP after install,
// without hand-editing felis.toml and the Secret.
type reconfigureSMTPMsg struct{}
// smtpResultMsg returns control to the root once the screen is done: applied
// (configured=true, with a recap) or backed out of (configured=false).
type smtpResultMsg struct {
configured bool
detail string
}
type smtpStep int
const (
esForm smtpStep = iota
esWorking
esDone
esError
)
type smtpApplyMsg struct{ err error }
// smtpModel drives the email relay form: collect → verify+apply → done/error,
// the storageModel machine with a single form and no chooser.
type smtpModel struct {
step smtpStep
form *huh.Form
sp spinner.Model
err error
in smtpInputs
width, height int
}
func newSMTPModel(in smtpInputs) *smtpModel {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = tuiLabel
if in.port == "" {
in.port = "587"
}
m := &smtpModel{step: esForm, sp: sp, in: in}
m.form = m.build()
return m
}
func (m *smtpModel) build() *huh.Form {
return m.sized(newFelisForm(huh.NewGroup(
huh.NewNote().
Title("Email (SMTP)").
Description("The relay Felis mails one-time codes through — email verification, email login and operator sign-in all need it. The password goes into a Kubernetes Secret; only the other fields are written to felis.toml. Saving sends one self-test message to the From address: nothing is written unless it is delivered."),
huh.NewInput().
Title("SMTP host").
Description("Your provider's relay, e.g. smtp.gmail.com or smtp.mailgun.org.").
Value(&m.in.host).
Validate(requiredStorageField("SMTP host")),
huh.NewInput().
Title("Port").
Description("587 = STARTTLS (most providers) · 465 = implicit TLS.").
Value(&m.in.port).
Validate(validateSMTPPort),
huh.NewInput().
Title("From address").
Description("The sender codes are mailed as, e.g. felis@your-domain. It must be an address this account is allowed to send as — providers reject a From on a domain you have not verified with them, and they usually do it only after the message body, not when you connect.").
Value(&m.in.from).
Validate(validateSMTPFrom),
huh.NewInput().
Title("Username").
Description("Optional — leave blank for an unauthenticated relay.").
Value(&m.in.username),
huh.NewInput().
Title("Password").
Description("Required when a username is set.").
EchoMode(huh.EchoModePassword).
Value(&m.in.password),
)))
}
func (m *smtpModel) sized(f *huh.Form) *huh.Form {
if m.width > 0 {
return f.WithWidth(m.width).WithHeight(m.height)
}
return f
}
func (m *smtpModel) setSize(w, h int) {
m.width, m.height = w, h
if m.form != nil {
m.form = m.form.WithWidth(w).WithHeight(h)
}
}
func (m *smtpModel) Init() tea.Cmd { return m.form.Init() }
func (m *smtpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case smtpApplyMsg:
if msg.err != nil {
m.step, m.err = esError, msg.err
return m, nil
}
m.step = esDone
return m, nil
case spinner.TickMsg:
if m.step == esWorking {
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
}
return m, nil
case tea.KeyMsg:
switch m.step {
case esForm:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
return m, smtpEmit(smtpResultMsg{})
}
case esDone:
switch msg.String() {
case "ctrl+c", "esc", "enter":
return m, smtpEmit(smtpResultMsg{configured: true, detail: smtpDetail(m.in)})
}
return m, nil
case esError:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
m.step, m.err = esForm, nil
m.form = m.build()
return m, m.form.Init()
case "enter":
m.step, m.err = esWorking, nil
return m, tea.Batch(m.sp.Tick, m.apply())
}
return m, nil
case esWorking:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
}
if m.step == esForm && 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.normalizeInputs()
m.step = esWorking
return m, tea.Batch(m.sp.Tick, m.apply())
case huh.StateAborted:
return m, smtpEmit(smtpResultMsg{})
}
return m, cmd
}
return m, nil
}
func smtpEmit(msg smtpResultMsg) tea.Cmd { return func() tea.Msg { return msg } }
func (m *smtpModel) apply() tea.Cmd {
in := m.in
return func() tea.Msg {
return smtpApplyMsg{err: applySMTPConfig(context.Background(), in)}
}
}
func (m *smtpModel) normalizeInputs() {
m.in.host = strings.TrimSpace(m.in.host)
m.in.port = strings.TrimSpace(m.in.port)
m.in.from = strings.TrimSpace(m.in.from)
m.in.username = strings.TrimSpace(m.in.username)
m.in.password = strings.TrimSpace(m.in.password)
}
func (m *smtpModel) View() string {
switch m.step {
case esWorking:
return " " + m.sp.View() + " " + tuiHint.Render("Delivering a self-test message, saving email settings and rolling the API…") + "\n"
case esDone:
var b strings.Builder
b.WriteString(tuiSuccessBanner("Email configured — codes are now mailed.") + "\n\n")
b.WriteString(tuiInfo("Relay → "+smtpDetail(m.in)) + "\n")
// Named because it is checkable: the operator can open that inbox and see the
// proof, rather than taking "configured" on faith.
b.WriteString(tuiHint.Render("A self-test message was delivered to "+m.in.from+".") + "\n")
b.WriteString("\n" + tuiAction("enter", "continue"))
return b.String()
case esError:
var b strings.Builder
b.WriteString(tuiErrorBanner("Could not configure email.") + "\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()
}
}
// arrowNavOK yields the horizontal arrows to the rail except while the form is
// taking text input (where ←/→ move the cursor).
func (m *smtpModel) arrowNavOK() bool { return m.step != esForm }
// smtpDetail is the one-line relay recap shown on the done screen.
func smtpDetail(in smtpInputs) string {
return in.host + ":" + in.port + " · from " + in.from
}
func validateSMTPPort(s string) error {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 1 || n > 65535 {
return errors.New("port must be a number 1-65535 (587 STARTTLS, 465 implicit TLS)")
}
return nil
}
func validateSMTPFrom(s string) error {
if !strings.Contains(strings.TrimSpace(s), "@") {
return errors.New("from must be the sender email address")
}
return nil
}
// currentSMTPInputs reads the relay already recorded in felis.toml so the form
// pre-fills the non-secret fields. The password (the felis-smtp Secret and its
// host copy) is deliberately never read back — it must be re-entered to change.
// Any read error falls back to a blank form rather than blocking reconfig.
func currentSMTPInputs() smtpInputs { return smtpInputsFrom(hostSetupConfigPath) }
// smtpInputsFrom is currentSMTPInputs for the config at path.
func smtpInputsFrom(path string) smtpInputs {
cfg, err := config.Load(path)
if err != nil || cfg.SMTP.Host == "" {
return smtpInputs{}
}
return smtpInputs{
host: cfg.SMTP.Host,
port: strconv.Itoa(cfg.SMTP.Port),
from: cfg.SMTP.From,
username: cfg.SMTP.Username,
}
}
// applySMTPConfig proves the relay works, then persists it and rolls felis-api:
// Ping (a full transaction — connect/STARTTLS/AUTH/MAIL FROM/RCPT/DATA, which
// delivers one self-test message to the From address) → [smtp] into both config
// files → the password into /etc/felis/smtp-password (hostcreds.go) → the
// felis-smtp Secret → the config Secret → rollout. A failed Ping
// leaves the install untouched, so a bad relay dies at the keyboard, not at a
// player's OTP.
//
// Ping really sends, because a cheaper probe cannot answer the question this
// screen exists to answer. Relays that validate sender identity — Fastmail, and
// it is not alone — return an unconditional 250 to MAIL FROM and only refuse at
// end-of-DATA. The earlier connect/AUTH/NOOP check therefore accepted a From on
// a domain the account could not send as, wrote the config, and left every OTP
// failing afterwards with this screen reporting success.
func applySMTPConfig(ctx context.Context, in smtpInputs) error {
port, err := strconv.Atoi(in.port)
if err != nil {
return fmt.Errorf("port %q is not a number", in.port)
}
var prev config.SMTPConfig
if cur, err := config.Load(hostSetupConfigPath); err == nil {
prev = cur.SMTP
}
// Ping under the posture felis api will send with, so a relay without
// STARTTLS is turned down here rather than at a player's first code.
if err := smtpRelay(setupSMTPConfig(in, port, prev), in.password).Ping(ctx); err != nil {
return err
}
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
cfg, err := config.Load(path)
if err != nil {
return err
}
cfg.SMTP = setupSMTPConfig(in, port, cfg.SMTP)
if err := writeConfig(path, cfg); err != nil {
return err
}
}
// The host copy first: should the Secret fail, the next installer run applies
// it from this file.
if err := writeHostCredential(hostSMTPPasswordPath, in.password); err != nil {
return fmt.Errorf("keep the relay password in %s: %w", hostSMTPPasswordPath, err)
}
if err := applySMTPSecret(ctx, in.password); err != nil {
return err
}
if err := applyFelisConfigSecret(ctx); err != nil {
return err
}
// Refresh the workload-namespace copies too (the reaper's warning path): the
// OTP path is already live in the control namespace, so a replica miss is
// reported but not fatal.
if err := replicateSMTPToWorkloadNamespace(ctx, in.password); err != nil {
fmt.Fprintf(os.Stderr, "felis setup: warning: email is configured, but refreshing the workload copies failed (pre-reap warning emails may stay suppressed): %v\n", err)
}
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
return err
}
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
}
// setupSMTPConfig is the [smtp] block this screen writes: the relay it just
// proved, plus the keys only an operator sets by hand (require_tls,
// max_per_hour), carried over from the block it replaces so reconfiguring the
// relay does not quietly reset them.
func setupSMTPConfig(in smtpInputs, port int, prev config.SMTPConfig) config.SMTPConfig {
return config.SMTPConfig{
Host: in.host,
Port: port,
From: in.from,
Username: in.username,
PasswordRef: platform.SMTPPasswordEnv,
MaxPerHour: prev.MaxPerHour,
RequireTLS: prev.RequireTLS,
}
}
// smtpSecretManifest renders the felis-smtp Secret for the given namespace, the
// one the receiving Deployment/CronJob resolves its secretKeyRef against (felis
// for felis-api, the workload namespace for the reaper's mirror). The namespace
// must be IN the manifest: kubectl rejects a manifest whose namespace conflicts
// with -n, so leaving the control namespace hardcoded made every workload-ns
// replica fail before it started. Rendered in-process and piped to
// `kubectl apply` — the password is never a command-line arg, so it never
// appears in the host process table.
func smtpSecretManifest(password, namespace string) ([]byte, error) {
secret := &corev1.Secret{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"},
ObjectMeta: metav1.ObjectMeta{Name: platform.SMTPSecretName, Namespace: namespace},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{
platform.SMTPSecretPasswordKey: password,
},
}
manifest, err := yaml.Marshal(secret)
if err != nil {
return nil, fmt.Errorf("render smtp secret: %w", err)
}
return manifest, nil
}
func applySMTPSecret(ctx context.Context, password string) error {
manifest, err := smtpSecretManifest(password, "felis")
if err != nil {
return err
}
return kubectlWithInput(ctx, manifest, "apply", "-f", "-")
}
// replicateSMTPToWorkloadNamespace refreshes the workload-namespace (minecraft)
// copy of felis-smtp after email is reconfigured. The reaper's CronJob runs
// there and resolves the password by local reference — a secretKeyRef is
// namespace-local — so without this refresh a later SMTP change would never
// reach the pre-reap warning emails. Deliberately OVERWRITES: this is a mirror
// of the control-namespace source, and a stale mirror is exactly the failure
// this closes. The felis-config mirror rides along in applyFelisConfigSecret,
// which every apply path refreshes.
func replicateSMTPToWorkloadNamespace(ctx context.Context, password string) error {
cfg, err := config.Load(hostSetupConfigPath)
if err != nil {
return err
}
ns := cfg.K8s.Namespace
if ns == "" || ns == "felis" {
return nil
}
smtpManifest, err := smtpSecretManifest(password, ns)
if err != nil {
return err
}
if err := kubectlWithInput(ctx, smtpManifest, "-n", ns, "apply", "-f", "-"); err != nil {
return fmt.Errorf("replicate %s to %s: %w", platform.SMTPSecretName, ns, err)
}
return nil
}