Unverified Commit 7d82402c authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)

parent d93c1b69
Loading
Loading
Loading
Loading
+20 −8
Changes for cmd/felis/api.go: 20 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -140,7 +140,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
	// Email one-time codes go through the [smtp] relay when one is configured; the
	// password is read from the env var password_ref names (default SMTPPasswordEnv,
	// injected from the felis-smtp Secret). No [smtp] host ⇒ mailer stays nil and
	// deliverOTP logs each code server-side (the pre-SMTP bootstrap posture).
	// every door that mails a code answers 503 mail_unavailable: a code that is
	// not mailed is never written anywhere else either.
	var mailer api.OTPMailer
	if cfg.SMTP.Host != "" {
		passRef := cfg.SMTP.PasswordRef
@@ -151,15 +152,12 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
		if cfg.SMTP.Username != "" && password == "" {
			fmt.Fprintf(stderr, "felis api: warning: [smtp] username is set but credentials env %s is empty — OTP sends will fail AUTH\n", passRef)
		}
		mailer = &mail.SMTP{
			Host:     cfg.SMTP.Host,
			Port:     cfg.SMTP.Port,
			From:     cfg.SMTP.From,
			Username: cfg.SMTP.Username,
			Password: password,
		mailer = smtpRelay(cfg.SMTP, password)
		if !cfg.SMTP.TLSRequired() {
			fmt.Fprintf(stderr, "felis api: warning: [smtp] %s may be sent codes without TLS (require_tls off or a relay on this host)\n", cfg.SMTP.Host)
		}
	} else {
		fmt.Fprintln(stderr, "felis api: [smtp] not configured — email one-time codes are logged, not mailed")
		fmt.Fprintln(stderr, "felis api: [smtp] not configured — email sign-in and verification are off (503 mail_unavailable); sign in with a passkey, or run felis setup to add a relay")
	}

	// Build subsystem (spec §16): the weak-SA build Job runs in the configured
@@ -824,3 +822,17 @@ func internalCallerTokens(getenv func(string) string) (api.CallerTokens, error)
	}
	return api.NewCallerTokens(tokens)
}

// smtpRelay is the relay [smtp] names, with the resolved password and the TLS
// posture config.SMTPConfig.TLSRequired picks. felis api, the reaper and the
// watchdog all send through it, so none can drift to a weaker posture.
func smtpRelay(c config.SMTPConfig, password string) *mail.SMTP {
	return &mail.SMTP{
		Host:       c.Host,
		Port:       c.Port,
		From:       c.From,
		Username:   c.Username,
		Password:   password,
		RequireTLS: c.TLSRequired(),
	}
}
+1 −8
Changes for cmd/felis/reaper.go: 1 added line, 8 removed lines.
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@ import (
	"felis.lolicon.best/internal/apis/felis/v1alpha1"
	"felis.lolicon.best/internal/backup"
	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/mail"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/reaper"
	corev1 "k8s.io/api/core/v1"
@@ -126,13 +125,7 @@ func cmdReaper(args []string, stdout, stderr io.Writer) int {
				}
				return email, nil
			},
			notifier: &mail.SMTP{
				Host:     cfg.SMTP.Host,
				Port:     cfg.SMTP.Port,
				From:     cfg.SMTP.From,
				Username: cfg.SMTP.Username,
				Password: password,
			},
			notifier: smtpRelay(cfg.SMTP, password),
		}
	} else {
		fmt.Fprintln(stderr, "felis reaper: [smtp] not configured — pre-reap warnings are logged and NOT marked sent")
+24 −11
Changes for cmd/felis/tui_smtp.go: 24 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -9,7 +9,6 @@ import (
	"strings"

	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/mail"
	"felis.lolicon.best/internal/platform"

	"github.com/charmbracelet/bubbles/spinner"
@@ -311,24 +310,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
	if err != nil {
		return fmt.Errorf("port %q is not a number", in.port)
	}
	relay := &mail.SMTP{Host: in.host, Port: port, From: in.from, Username: in.username, Password: in.password}
	if err := relay.Ping(ctx); err != nil {
	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
	}

	smtpCfg := config.SMTPConfig{
		Host:        in.host,
		Port:        port,
		From:        in.from,
		Username:    in.username,
		PasswordRef: platform.SMTPPasswordEnv,
	}
	for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
		cfg, err := config.Load(path)
		if err != nil {
			return err
		}
		cfg.SMTP = smtpCfg
		cfg.SMTP = setupSMTPConfig(in, port, cfg.SMTP)
		if err := writeConfig(path, cfg); err != nil {
			return err
		}
@@ -351,6 +348,22 @@ func applySMTPConfig(ctx context.Context, in smtpInputs) error {
	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
+39 −0
Changes for cmd/felis/tui_smtp_test.go: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"reflect"
	"strings"
	"testing"

	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/mail"
	"sigs.k8s.io/yaml"
)

@@ -35,3 +38,39 @@ func TestSMTPSecretManifestCarriesTargetNamespace(t *testing.T) {
		}
	}
}

// TestSMTPRelayCarriesTLSPosture: the relay felis api, the reaper and the
// watchdog send through refuses plaintext for a remote host and allows it for
// one on this host, as [smtp] says.
func TestSMTPRelayCarriesTLSPosture(t *testing.T) {
	remote := smtpRelay(config.SMTPConfig{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis"}, "pw")
	want := &mail.SMTP{Host: "smtp.example.net", Port: 587, From: "[email protected]", Username: "felis", Password: "pw", RequireTLS: true}
	if !reflect.DeepEqual(remote, want) {
		t.Errorf("remote relay = %+v, want %+v", remote, want)
	}
	if local := smtpRelay(config.SMTPConfig{Host: "127.0.0.1", Port: 25, From: "[email protected]"}, ""); local.RequireTLS {
		t.Error("a relay on this host must not require TLS by default")
	}
}

// TestSetupSMTPConfigKeepsHandSetKeys: re-running the email screen replaces the
// relay but keeps require_tls and max_per_hour, which only an operator sets.
func TestSetupSMTPConfigKeepsHandSetKeys(t *testing.T) {
	off := false
	prev := config.SMTPConfig{Host: "old.example.net", Port: 25, From: "[email protected]", MaxPerHour: 500, RequireTLS: &off}
	in := smtpInputs{host: "smtp.example.net", from: "[email protected]", username: "felis"}
	got := setupSMTPConfig(in, 465, prev)
	if got.Host != "smtp.example.net" || got.Port != 465 || got.From != "[email protected]" ||
		got.Username != "felis" || got.PasswordRef != "FELIS_SMTP_PASSWORD" {
		t.Errorf("relay fields = %+v", got)
	}
	if got.MaxPerHour != 500 {
		t.Errorf("max_per_hour = %d, want 500", got.MaxPerHour)
	}
	if got.RequireTLS == nil || *got.RequireTLS {
		t.Errorf("require_tls = %v, want the operator's false", got.RequireTLS)
	}
	if fresh := setupSMTPConfig(in, 587, config.SMTPConfig{}); fresh.RequireTLS != nil || fresh.MaxPerHour != 0 {
		t.Errorf("first setup = %+v, want require_tls and max_per_hour unset", fresh)
	}
}
+1 −2
Changes for cmd/felis/watchdog.go: 1 added line, 2 removed lines.
Original line number Diff line number Diff line
@@ -13,7 +13,6 @@ import (
	"time"

	"felis.lolicon.best/internal/config"
	"felis.lolicon.best/internal/mail"
	"felis.lolicon.best/internal/offsite"
	"felis.lolicon.best/internal/platform"
	"felis.lolicon.best/internal/store"
@@ -245,7 +244,7 @@ func sendAlert(ctx context.Context, cfg *config.Config, state *watchdog.State, s
	if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" {
		password = os.Getenv(ref)
	}
	relay := &mail.SMTP{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From, Username: cfg.SMTP.Username, Password: password}
	relay := smtpRelay(cfg.SMTP, password)
	var errs []error
	for _, to := range state.Recipients {
		if err := relay.SendNotice(ctx, to, subject, body); err != nil {
Loading