修复初始化端口持久化和系统服务权限
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 21:00:38 +08:00
parent 76635274fa
commit 18896997df
6 changed files with 263 additions and 4 deletions

View File

@ -7,6 +7,7 @@ Wants=network-online.target
Type=simple
User=smail
Group=smail
EnvironmentFile=/etc/smail/smail.env
WorkingDirectory=/var/lib/smail
ExecStart=/usr/local/lib/smail/smail
Restart=on-failure
@ -16,10 +17,12 @@ RuntimeDirectoryMode=0750
# 目录由 smail 进程拥有,允许主服务创建受保护的本机控制套接字。
NoNewPrivileges=true
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths=/var/lib/smail /run/smail
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@ -72,3 +72,30 @@ func TestInstallerDisablesAdminAutostart(t *testing.T) {
t.Fatal("installer does not clear legacy smail-admin enablement")
}
}
func TestSmailServiceUsesOnlyBindServiceCapability(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
unit, err := os.ReadFile(filepath.Join(root, "deploy", "systemd", "smail.service"))
if err != nil {
t.Fatal(err)
}
content := string(unit)
for _, required := range []string{
"User=smail",
"Group=smail",
"EnvironmentFile=/etc/smail/smail.env",
"NoNewPrivileges=true",
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
} {
if !strings.Contains(content, required) {
t.Fatalf("smail unit is missing %q", required)
}
}
if strings.Contains(content, "User=root") {
t.Fatal("smail unit must not run as root")
}
if strings.Count(content, "CAP_NET_BIND_SERVICE") != 2 {
t.Fatal("smail unit grants unexpected Linux capabilities")
}
}

View File

@ -2,6 +2,8 @@ package config
import (
"fmt"
"os"
"strconv"
"strings"
"sync/atomic"
@ -55,21 +57,59 @@ func CurrentHTTPConfig() HTTPConfig {
if manager := CurrentManager(); manager != nil {
runtime := manager.Snapshot()
if runtime.Generation != 0 {
return cloneHTTPConfig(runtime.Root.HTTP)
return ResolveHTTPPortDefaults(runtime.Root.HTTP, RuntimeSetupPort())
}
}
cfg := CurrentConfig()
if cfg == nil {
return HTTPConfig{}
return ResolveHTTPPortDefaults(HTTPConfig{}, RuntimeSetupPort())
}
mode := strings.ToLower(strings.TrimSpace(cfg.TLSMode))
return HTTPConfig{
return ResolveHTTPPortDefaults(HTTPConfig{
Port: cfg.HttpPort,
HTTPSPort: cfg.HttpsPort,
HTTPSEnabled: cfg.HttpsEnabled != 2,
TLSMode: mode,
TrustedProxyCIDRs: append([]string(nil), cfg.TrustedProxyCIDRs...),
}, cfg.GetSetupPort())
}
func ResolveHTTPPortDefaults(value HTTPConfig, setupPort int) HTTPConfig {
result := cloneHTTPConfig(value)
result.TLSMode = strings.ToLower(strings.TrimSpace(result.TLSMode))
if result.Port == 0 {
result.Port = environmentPort("SMAIL_HTTP_PORT")
if result.Port == 0 && validServicePort(setupPort) {
result.Port = setupPort
}
if result.Port == 0 {
result.Port = 80
}
}
if result.TLSMode == HTTPSTLSModeProxy {
result.HTTPSPort = 0
result.HTTPSEnabled = false
return result
}
if result.HTTPSPort == 0 {
result.HTTPSPort = environmentPort("SMAIL_HTTPS_PORT")
if result.HTTPSPort == 0 {
result.HTTPSPort = 443
}
}
return result
}
func environmentPort(name string) int {
port, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
if err != nil || !validServicePort(port) {
return 0
}
return port
}
func validServicePort(port int) bool {
return port > 0 && port <= 65535
}
// CurrentWebDomain 返回当前进程实际使用的 Web 域名。

View File

@ -0,0 +1,51 @@
package config
import "testing"
func TestResolveHTTPPortDefaultsPreservesConfiguredPorts(t *testing.T) {
t.Setenv("SMAIL_HTTP_PORT", "19001")
t.Setenv("SMAIL_HTTPS_PORT", "19002")
got := ResolveHTTPPortDefaults(HTTPConfig{
Port: 9001,
HTTPSPort: 9002,
HTTPSEnabled: true,
}, 18080)
if got.Port != 9001 || got.HTTPSPort != 9002 {
t.Fatalf("HTTP ports = %d/%d, want 9001/9002", got.Port, got.HTTPSPort)
}
}
func TestResolveHTTPPortDefaultsUsesEnvironmentPorts(t *testing.T) {
t.Setenv("SMAIL_HTTP_PORT", "9001")
t.Setenv("SMAIL_HTTPS_PORT", "9002")
got := ResolveHTTPPortDefaults(HTTPConfig{HTTPSEnabled: true}, 18080)
if got.Port != 9001 || got.HTTPSPort != 9002 {
t.Fatalf("HTTP ports = %d/%d, want 9001/9002", got.Port, got.HTTPSPort)
}
}
func TestResolveHTTPPortDefaultsUsesSetupAndDirectDefaults(t *testing.T) {
t.Setenv("SMAIL_HTTP_PORT", "")
t.Setenv("SMAIL_HTTPS_PORT", "")
got := ResolveHTTPPortDefaults(HTTPConfig{HTTPSEnabled: true}, 9001)
if got.Port != 9001 || got.HTTPSPort != 443 {
t.Fatalf("HTTP ports = %d/%d, want 9001/443", got.Port, got.HTTPSPort)
}
}
func TestResolveHTTPPortDefaultsDisablesInternalHTTPSInProxyMode(t *testing.T) {
t.Setenv("SMAIL_HTTP_PORT", "9001")
t.Setenv("SMAIL_HTTPS_PORT", "9002")
got := ResolveHTTPPortDefaults(HTTPConfig{
HTTPSPort: 9443,
HTTPSEnabled: true,
TLSMode: " PROXY ",
}, 18080)
if got.Port != 9001 || got.HTTPSPort != 0 || got.HTTPSEnabled {
t.Fatalf("proxy HTTP config = %+v, want HTTP 9001 with internal HTTPS disabled", got)
}
}

View File

@ -17,7 +17,13 @@ func Finish() error {
if err != nil {
return errors.Wrap(err)
}
portsChanged := applyServicePorts(cfg)
if cfg.IsSetupReady() {
if portsChanged {
if err = config.WriteConfig(cfg); err != nil {
return errors.Wrap(err)
}
}
config.SetInitState(true)
return nil
}
@ -37,3 +43,23 @@ func Finish() error {
}
return nil
}
func applyServicePorts(cfg *config.Config) bool {
if cfg == nil {
return false
}
resolved := config.ResolveHTTPPortDefaults(config.HTTPConfig{
Port: cfg.HttpPort,
HTTPSPort: cfg.HttpsPort,
HTTPSEnabled: cfg.HttpsEnabled != 2,
TLSMode: cfg.TLSMode,
}, cfg.GetSetupPort())
changed := cfg.HttpPort != resolved.Port || cfg.HttpsPort != resolved.HTTPSPort
cfg.HttpPort = resolved.Port
cfg.HttpsPort = resolved.HTTPSPort
if resolved.TLSMode == config.HTTPSTLSModeProxy && cfg.HttpsEnabled != 2 {
cfg.HttpsEnabled = 2
changed = true
}
return changed
}

View File

@ -0,0 +1,112 @@
package setup
import (
"os"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
func TestFinishPersistsServicePorts(t *testing.T) {
prepareFinishConfigTest(t)
t.Setenv("SMAIL_HTTP_PORT", "9001")
t.Setenv("SMAIL_HTTPS_PORT", "9002")
config.SetRuntimeSetupPort(18080)
if err := config.WriteConfig(&config.Config{
SetupState: config.SetupStateBootstrap,
HttpsEnabled: 1,
}); err != nil {
t.Fatal(err)
}
if err := Finish(); err != nil {
t.Fatal(err)
}
loaded, err := config.ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.HttpPort != 9001 || loaded.HttpsPort != 9002 {
t.Fatalf("persisted HTTP ports = %d/%d, want 9001/9002", loaded.HttpPort, loaded.HttpsPort)
}
if loaded.SetupState != config.SetupStateReady || !loaded.IsInit {
t.Fatalf("setup state = %q, isInit = %t", loaded.SetupState, loaded.IsInit)
}
}
func TestFinishRepairsReadyConfigWithMissingPorts(t *testing.T) {
prepareFinishConfigTest(t)
t.Setenv("SMAIL_HTTP_PORT", "9001")
t.Setenv("SMAIL_HTTPS_PORT", "9002")
if err := config.WriteConfig(&config.Config{
SetupState: config.SetupStateReady,
IsInit: true,
HttpsEnabled: 1,
}); err != nil {
t.Fatal(err)
}
if err := Finish(); err != nil {
t.Fatal(err)
}
loaded, err := config.ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.HttpPort != 9001 || loaded.HttpsPort != 9002 {
t.Fatalf("repaired HTTP ports = %d/%d, want 9001/9002", loaded.HttpPort, loaded.HttpsPort)
}
}
func TestFinishKeepsInternalHTTPSDisabledInProxyMode(t *testing.T) {
prepareFinishConfigTest(t)
t.Setenv("SMAIL_HTTP_PORT", "9001")
t.Setenv("SMAIL_HTTPS_PORT", "9002")
if err := config.WriteConfig(&config.Config{
SetupState: config.SetupStateBootstrap,
TLSMode: config.HTTPSTLSModeProxy,
HttpsPort: 9443,
HttpsEnabled: 1,
}); err != nil {
t.Fatal(err)
}
if err := Finish(); err != nil {
t.Fatal(err)
}
loaded, err := config.ReadConfig()
if err != nil {
t.Fatal(err)
}
if loaded.HttpPort != 9001 || loaded.HttpsPort != 0 || loaded.HttpsEnabled != 2 {
t.Fatalf("proxy config = HTTP %d, HTTPS %d, enabled %d", loaded.HttpPort, loaded.HttpsPort, loaded.HttpsEnabled)
}
}
func prepareFinishConfigTest(t *testing.T) {
t.Helper()
previousRoot := config.ROOT_PATH
previousInstance := config.Instance
previousManager := config.CurrentManager()
previousSetupPort := config.RuntimeSetupPort()
previousInitState := config.IsInit
config.ROOT_PATH = t.TempDir() + string(os.PathSeparator)
config.Instance = nil
config.PublishConfigSnapshot(nil)
config.SetRuntimeManager(nil)
config.SetRuntimeSetupPort(0)
config.SetInitState(false)
t.Cleanup(func() {
config.ROOT_PATH = previousRoot
config.Instance = previousInstance
config.PublishConfigSnapshot(previousInstance)
config.SetRuntimeManager(previousManager)
config.SetRuntimeSetupPort(previousSetupPort)
config.SetInitState(previousInitState)
})
}