fix(api): 未配置 SMTP 时发码门统一 503 mail_unavailable 且不再把验证码写日志,非本机中继默认强制 STARTTLS(require_tls)
This commit is contained in:
27 files changed
+529
-87
No files matched your search
+20
-8
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user