diff --git a/deploy/systemd/smail.service b/deploy/systemd/smail.service index 7018511..3cd4140 100644 --- a/deploy/systemd/smail.service +++ b/deploy/systemd/smail.service @@ -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 \ No newline at end of file +WantedBy=multi-user.target diff --git a/server/cmd/smail-admin/systemd_policy_test.go b/server/cmd/smail-admin/systemd_policy_test.go index 17bac5a..de4873e 100644 --- a/server/cmd/smail-admin/systemd_policy_test.go +++ b/server/cmd/smail-admin/systemd_policy_test.go @@ -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") + } +} diff --git a/server/config/runtime_access.go b/server/config/runtime_access.go index 81593b1..78c362b 100644 --- a/server/config/runtime_access.go +++ b/server/config/runtime_access.go @@ -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 域名。 diff --git a/server/config/runtime_access_port_test.go b/server/config/runtime_access_port_test.go new file mode 100644 index 0000000..faece9b --- /dev/null +++ b/server/config/runtime_access_port_test.go @@ -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) + } +} diff --git a/server/services/setup/finish.go b/server/services/setup/finish.go index 48b1ad6..63e6444 100644 --- a/server/services/setup/finish.go +++ b/server/services/setup/finish.go @@ -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 +} diff --git a/server/services/setup/finish_test.go b/server/services/setup/finish_test.go new file mode 100644 index 0000000..7c70ca1 --- /dev/null +++ b/server/services/setup/finish_test.go @@ -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) + }) +}