Scert/backend/internal/config/config.go

637 lines
23 KiB
Go

package config
import (
"encoding/json"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"os/user"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const CurrentVersion = 1
const ConfigFormat = "json"
const LinuxInstallationMetadataPath = "/scert/conf/installation.json"
type installationMetadata struct {
CertificateRoot string `json:"certificate_root"`
}
type Config struct {
Version int `json:"version"`
SetupReady bool `json:"setup_ready"`
Admin AdminConfig `json:"admin"`
Server ServerConfig `json:"server"`
Web WebConfig `json:"web"`
Database DatabaseConfig `json:"database"`
Paths PathsConfig `json:"paths"`
ACME ACMEConfig `json:"acme"`
DNS DNSConfig `json:"dns"`
Notification NotificationConfig `json:"notification"`
}
type AdminConfig struct {
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
}
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
HTTPSMode string `json:"https_mode"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
}
type WebConfig struct {
Enabled bool `json:"enabled"`
Hostname string `json:"hostname"`
PublicIP string `json:"public_ip"`
InitReady bool `json:"init_ready"`
}
type DatabaseConfig struct {
Driver string `json:"driver"`
DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"`
MaxIdleConns int `json:"max_idle_conns"`
ConnMaxLifetimeMins int `json:"conn_max_lifetime_mins"`
}
type PathsConfig struct {
Install string `json:"install"`
Certificate string `json:"certificate"`
Config string `json:"config"`
Data string `json:"data"`
Log string `json:"log"`
Backup string `json:"backup"`
}
type ACMEConfig struct {
CA string `json:"ca"`
Environment string `json:"environment"`
AccountEmail string `json:"account_email"`
RenewalCheckIntervalDays int `json:"renewal_check_interval_days"`
RenewalNearExpiryDays int `json:"renewal_near_expiry_days"`
RenewalThresholdDays int `json:"renewal_threshold_days"`
RenewalUrgentThresholdDays int `json:"renewal_urgent_threshold_days"`
RenewalMaxRetries int `json:"renewal_max_retries"`
RenewalRetryIntervalMins int `json:"renewal_retry_interval_mins"`
PropagationTimeoutSecs int `json:"propagation_timeout_secs"`
PropagationPollingIntervalSecs int `json:"propagation_polling_interval_secs"`
}
type DNSConfig struct {
Provider string `json:"provider"`
CredentialRef string `json:"credential_ref"`
VerificationState string `json:"verification_state"`
}
type NotificationConfig struct {
HTTPEnabled bool `json:"http_enabled"`
SMTPEnabled bool `json:"smtp_enabled"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HTTPPath string `json:"http_path"`
HTTPLoginPath string `json:"http_login_path"`
HTTPAuthMode string `json:"http_auth_mode"`
HTTPCredentialRef string `json:"http_credential_ref"`
HTTPHeaders map[string]string `json:"http_headers"`
EventMode string `json:"event_mode"`
IncludeDeploymentDetails bool `json:"include_deployment_details"`
IncludeCertificateSerial bool `json:"include_certificate_serial"`
IncludeManagementURL bool `json:"include_management_url"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
Recipients []string `json:"recipients"`
TimeoutSecs int `json:"timeout_secs"`
RetryCount int `json:"retry_count"`
RetryIntervalsSecs []int `json:"retry_intervals_secs"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPCredentialRef string `json:"smtp_credential_ref"`
SMTPMode string `json:"smtp_mode"`
SMTPAllowPlaintextAuth bool `json:"smtp_allow_plaintext_auth"`
}
func Default(baseDir string) Config {
paths := DefaultPaths(baseDir)
return Config{
Version: CurrentVersion,
SetupReady: false,
Admin: AdminConfig{},
Server: ServerConfig{Host: "0.0.0.0", Port: 8080, HTTPSMode: "direct"},
Web: WebConfig{Enabled: true},
Database: DatabaseConfig{
Driver: "sqlite", DSN: filepath.Join(paths.Data, "scert.db"),
MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30,
},
Paths: paths,
ACME: ACMEConfig{
CA: "letsencrypt",
Environment: "staging",
RenewalCheckIntervalDays: 3,
RenewalNearExpiryDays: 10,
RenewalThresholdDays: 6,
RenewalUrgentThresholdDays: 3,
RenewalMaxRetries: 3,
RenewalRetryIntervalMins: 30,
PropagationTimeoutSecs: 300,
PropagationPollingIntervalSecs: 5,
},
DNS: DNSConfig{VerificationState: "not_configured"},
Notification: NotificationConfig{
HTTPHeaders: map[string]string{},
HTTPPath: "/api/email/send",
HTTPLoginPath: "/api/login",
HTTPAuthMode: "none",
EventMode: "detailed",
SenderName: "Scert",
TimeoutSecs: 10,
RetryCount: 3,
RetryIntervalsSecs: []int{30, 120, 600},
SMTPPort: 587,
SMTPMode: "starttls",
},
}
}
func DefaultPaths(baseDir string) PathsConfig {
if runtime.GOOS == "linux" {
certificateRoot := "/ssl"
if strings.TrimSpace(baseDir) == "" {
if installedRoot, err := readInstalledCertificateRoot(LinuxInstallationMetadataPath); err == nil {
certificateRoot = installedRoot
}
}
return PathsConfig{
Install: "/scert", Certificate: certificateRoot, Config: "/scert/conf",
Data: "/scert/data", Log: "/scert/log", Backup: "/scert/backups",
}
}
if strings.TrimSpace(baseDir) == "" {
baseDir = "."
}
install := filepath.Join(baseDir, "Scert")
return PathsConfig{
Install: install, Certificate: filepath.Join(install, "ssl"),
Config: filepath.Join(install, "config"), Data: filepath.Join(install, "data"),
Log: filepath.Join(install, "logs"), Backup: filepath.Join(install, "backups"),
}
}
func readInstalledCertificateRoot(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
var metadata installationMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return "", fmt.Errorf("解析安装路径记录失败:%w", err)
}
root := filepath.Clean(strings.TrimSpace(metadata.CertificateRoot))
if !filepath.IsAbs(root) || root == string(filepath.Separator) {
return "", fmt.Errorf("安装路径记录中的证书目录无效")
}
return root, nil
}
func Load(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("读取配置文件失败:%w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("解析配置文件失败:%w", err)
}
cfg.Normalize()
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// LoadWithEnv 读取配置文件,并应用受控的 SCERT_* 环境变量覆盖。
// 环境变量只影响当前进程,不会回写配置文件。
func LoadWithEnv(path string) (Config, error) {
cfg, err := Load(path)
if err != nil {
return Config{}, err
}
if err := cfg.ApplyEnvironment(); err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// ApplyEnvironment 将部署环境常用配置映射到当前配置。
// 未设置的变量保持配置文件中的值,敏感值只从环境读取,不写入日志或配置文件。
func (c *Config) ApplyEnvironment() error {
if c == nil {
return fmt.Errorf("配置不能为空")
}
stringOverrides := []struct {
name string
target *string
}{
{"SCERT_SERVER_HOST", &c.Server.Host},
{"SCERT_SERVER_HTTPS_MODE", &c.Server.HTTPSMode},
{"SCERT_WEB_HOSTNAME", &c.Web.Hostname},
{"SCERT_WEB_PUBLIC_IP", &c.Web.PublicIP},
{"SCERT_DATABASE_DRIVER", &c.Database.Driver},
{"SCERT_DATABASE_DSN", &c.Database.DSN},
{"SCERT_ACME_ENVIRONMENT", &c.ACME.Environment},
{"SCERT_ACME_ACCOUNT_EMAIL", &c.ACME.AccountEmail},
{"SCERT_DNS_PROVIDER", &c.DNS.Provider},
{"SCERT_DNS_CREDENTIAL_REF", &c.DNS.CredentialRef},
{"SCERT_NOTIFICATION_HTTP_CREDENTIAL_REF", &c.Notification.HTTPCredentialRef},
{"SCERT_NOTIFICATION_SMTP_CREDENTIAL_REF", &c.Notification.SMTPCredentialRef},
}
for _, override := range stringOverrides {
if value, ok := os.LookupEnv(override.name); ok {
*override.target = strings.TrimSpace(value)
}
}
boolOverrides := []struct {
name string
target *bool
}{
{"SCERT_WEB_ENABLED", &c.Web.Enabled},
}
for _, override := range boolOverrides {
value, ok := os.LookupEnv(override.name)
if !ok {
continue
}
parsed, err := parseEnvironmentBool(override.name, value)
if err != nil {
return err
}
*override.target = parsed
}
intOverrides := []struct {
name string
target *int
}{
{"SCERT_SERVER_PORT", &c.Server.Port},
{"SCERT_ACME_RENEWAL_CHECK_INTERVAL_DAYS", &c.ACME.RenewalCheckIntervalDays},
{"SCERT_ACME_RENEWAL_NEAR_EXPIRY_DAYS", &c.ACME.RenewalNearExpiryDays},
{"SCERT_ACME_RENEWAL_THRESHOLD_DAYS", &c.ACME.RenewalThresholdDays},
{"SCERT_ACME_RENEWAL_URGENT_THRESHOLD_DAYS", &c.ACME.RenewalUrgentThresholdDays},
{"SCERT_ACME_RENEWAL_MAX_RETRIES", &c.ACME.RenewalMaxRetries},
{"SCERT_ACME_RENEWAL_RETRY_INTERVAL_MINS", &c.ACME.RenewalRetryIntervalMins},
{"SCERT_ACME_PROPAGATION_TIMEOUT_SECS", &c.ACME.PropagationTimeoutSecs},
{"SCERT_ACME_PROPAGATION_POLLING_INTERVAL_SECS", &c.ACME.PropagationPollingIntervalSecs},
}
for _, override := range intOverrides {
value, ok := os.LookupEnv(override.name)
if !ok {
continue
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return fmt.Errorf("环境变量 %s 必须是整数:%w", override.name, err)
}
*override.target = parsed
}
return nil
}
func parseEnvironmentBool(name, value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("环境变量 %s 必须是 true 或 false", name)
}
}
func (c *Config) Normalize() {
if c == nil {
return
}
if c.Server.Host == "" {
c.Server.Host = "0.0.0.0"
}
if c.Server.HTTPSMode == "" {
c.Server.HTTPSMode = "direct"
}
if c.Web.Hostname == "" && c.Web.PublicIP == "" && !c.SetupReady {
c.Web.Enabled = true
}
if c.DNS.VerificationState == "" {
c.DNS.VerificationState = "not_configured"
}
if c.ACME.PropagationTimeoutSecs == 0 {
c.ACME.PropagationTimeoutSecs = 300
}
if c.ACME.RenewalCheckIntervalDays == 0 {
c.ACME.RenewalCheckIntervalDays = 3
}
if c.ACME.RenewalNearExpiryDays == 0 {
c.ACME.RenewalNearExpiryDays = 10
}
if c.ACME.RenewalThresholdDays == 0 {
c.ACME.RenewalThresholdDays = 6
}
if c.ACME.RenewalUrgentThresholdDays == 0 {
c.ACME.RenewalUrgentThresholdDays = 3
}
if c.ACME.RenewalMaxRetries == 0 {
c.ACME.RenewalMaxRetries = 3
}
if c.ACME.RenewalRetryIntervalMins == 0 {
c.ACME.RenewalRetryIntervalMins = 30
}
if c.ACME.PropagationPollingIntervalSecs == 0 {
c.ACME.PropagationPollingIntervalSecs = 5
}
if c.Notification.HTTPPath == "" {
c.Notification.HTTPPath = "/api/email/send"
}
if c.Notification.HTTPLoginPath == "" {
c.Notification.HTTPLoginPath = "/api/login"
}
if c.Notification.HTTPAuthMode == "" {
c.Notification.HTTPAuthMode = "none"
}
if c.Notification.EventMode == "" {
c.Notification.EventMode = "detailed"
}
if c.Notification.SenderName == "" {
c.Notification.SenderName = "Scert"
}
if c.Notification.TimeoutSecs == 0 {
c.Notification.TimeoutSecs = 10
}
if c.Notification.RetryCount == 0 {
c.Notification.RetryCount = 3
}
if len(c.Notification.RetryIntervalsSecs) == 0 {
c.Notification.RetryIntervalsSecs = []int{30, 120, 600}
}
if c.Notification.HTTPHeaders == nil {
c.Notification.HTTPHeaders = map[string]string{}
}
if c.Notification.SMTPPort == 0 {
c.Notification.SMTPPort = 587
}
if c.Notification.SMTPMode == "" {
c.Notification.SMTPMode = "starttls"
}
}
func (c Config) Save(path string) error {
if err := c.Validate(); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("创建配置目录失败:%w", err)
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("编码配置失败:%w", err)
}
data = append(data, '\n')
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
return fmt.Errorf("写入临时配置文件失败:%w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("替换配置文件失败:%w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("设置配置文件权限失败:%w", err)
}
if err := applyLinuxServiceOwnership(filepath.Dir(path), path); err != nil {
return fmt.Errorf("设置配置文件归属失败:%w", err)
}
return nil
}
// applyLinuxServiceOwnership 在 root 初始化配置时,将配置目录和文件交给 Scert 服务用户管理。
// 非 Linux、非 root 进程或系统尚未创建 Scert 用户时保持原有行为,便于开发环境使用。
func applyLinuxServiceOwnership(directory, path string) error {
if runtime.GOOS != "linux" || os.Geteuid() != 0 {
return nil
}
serviceUser, err := user.Lookup("Scert")
if err != nil {
return nil
}
serviceGroup, err := user.LookupGroup("Scert")
if err != nil {
return nil
}
uid, err := strconv.Atoi(serviceUser.Uid)
if err != nil {
return fmt.Errorf("解析 Scert 用户编号失败:%w", err)
}
gid, err := strconv.Atoi(serviceGroup.Gid)
if err != nil {
return fmt.Errorf("解析 Scert 用户组编号失败:%w", err)
}
if err := os.Chown(directory, uid, gid); err != nil {
return err
}
if err := os.Chmod(directory, 0o700); err != nil {
return err
}
return os.Chown(path, uid, gid)
}
func (c Config) Validate() error {
if c.Version != CurrentVersion {
return fmt.Errorf("配置版本不兼容:%d", c.Version)
}
if strings.TrimSpace(c.Server.Host) == "" {
return fmt.Errorf("服务监听地址不能为空")
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("服务监听端口无效:%d", c.Server.Port)
}
if c.Server.HTTPSMode != "direct" && c.Server.HTTPSMode != "proxy" {
return fmt.Errorf("HTTPS 模式无效:%s", c.Server.HTTPSMode)
}
if c.Database.Driver != "sqlite" && c.Database.Driver != "mysql" {
return fmt.Errorf("数据库驱动无效:%s", c.Database.Driver)
}
if strings.TrimSpace(c.Database.DSN) == "" {
return fmt.Errorf("数据库连接信息不能为空")
}
if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
return fmt.Errorf("数据库连接池配置无效")
}
if c.Database.ConnMaxLifetimeMins < 0 {
return fmt.Errorf("数据库连接生命周期不能为负数")
}
for name, value := range map[string]string{
"安装目录": c.Paths.Install, "证书目录": c.Paths.Certificate, "配置目录": c.Paths.Config,
"数据目录": c.Paths.Data, "日志目录": c.Paths.Log, "备份目录": c.Paths.Backup,
} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s不能为空", name)
}
}
if c.ACME.CA != "letsencrypt" {
return fmt.Errorf("当前仅支持 Let's Encrypt")
}
if c.ACME.Environment != "staging" && c.ACME.Environment != "production" {
return fmt.Errorf("ACME 环境无效:%s", c.ACME.Environment)
}
if c.ACME.RenewalThresholdDays < 1 {
return fmt.Errorf("续期阈值必须大于 0")
}
if c.ACME.RenewalCheckIntervalDays < 1 || c.ACME.RenewalNearExpiryDays < 1 || c.ACME.RenewalUrgentThresholdDays < 1 {
return fmt.Errorf("续期检查阈值必须大于 0")
}
if c.ACME.RenewalUrgentThresholdDays > c.ACME.RenewalThresholdDays {
return fmt.Errorf("紧急续期阈值不能大于自动续期阈值")
}
if c.ACME.RenewalThresholdDays > c.ACME.RenewalNearExpiryDays {
return fmt.Errorf("自动续期阈值不能大于临近到期检查阈值")
}
if c.ACME.RenewalMaxRetries < 1 || c.ACME.RenewalMaxRetries > 10 {
return fmt.Errorf("续期最大重试次数必须在 1 到 10 次之间")
}
if c.ACME.RenewalRetryIntervalMins < 1 || c.ACME.RenewalRetryIntervalMins > 1440 {
return fmt.Errorf("续期重试间隔必须在 1 到 1440 分钟之间")
}
if c.ACME.PropagationTimeoutSecs < 1 || c.ACME.PropagationTimeoutSecs > 86400 {
return fmt.Errorf("DNS 传播等待时间必须在 1 到 86400 秒之间")
}
if c.ACME.PropagationPollingIntervalSecs < 1 {
return fmt.Errorf("DNS 传播检测间隔必须大于 0")
}
if c.ACME.PropagationPollingIntervalSecs > c.ACME.PropagationTimeoutSecs {
return fmt.Errorf("DNS 传播检测间隔不能大于传播等待时间")
}
if c.DNS.VerificationState != "not_configured" && c.DNS.VerificationState != "pending" && c.DNS.VerificationState != "verified" && c.DNS.VerificationState != "failed" {
return fmt.Errorf("DNS 验证状态无效:%s", c.DNS.VerificationState)
}
if c.SetupReady {
if strings.TrimSpace(c.Admin.Username) == "" || strings.TrimSpace(c.Admin.PasswordHash) == "" {
return fmt.Errorf("初始化完成时必须配置超级管理员账号")
}
adminEmail := strings.ToLower(strings.TrimSpace(c.Admin.Email))
parsedEmail, emailErr := mail.ParseAddress(adminEmail)
if adminEmail == "" || emailErr != nil || parsedEmail.Address != adminEmail || parsedEmail.Name != "" {
return fmt.Errorf("初始化完成时必须配置合法的超级管理员绑定邮箱")
}
if c.DNS.VerificationState != "verified" {
return fmt.Errorf("初始化完成时必须先通过 DNS API 验证")
}
if strings.TrimSpace(c.Web.Hostname) == "" && strings.TrimSpace(c.Web.PublicIP) == "" {
return fmt.Errorf("初始化完成时必须配置管理页面域名或公网 IP")
}
}
if c.Notification.HTTPAuthMode != "none" && c.Notification.HTTPAuthMode != "bearer" && c.Notification.HTTPAuthMode != "hmac_sha256" && c.Notification.HTTPAuthMode != "session" {
return fmt.Errorf("HTTP 通知认证方式无效:%s", c.Notification.HTTPAuthMode)
}
if c.Notification.HTTPAuthMode != "none" && strings.TrimSpace(c.Notification.HTTPCredentialRef) == "" {
return fmt.Errorf("HTTP 通知启用认证时必须配置凭据引用")
}
if c.Notification.EventMode != "detailed" && c.Notification.EventMode != "unified" {
return fmt.Errorf("通知事件模式无效:%s", c.Notification.EventMode)
}
if c.Notification.SMTPMode != "plain" && c.Notification.SMTPMode != "starttls" && c.Notification.SMTPMode != "implicit_tls" {
return fmt.Errorf("\u0053\u004d\u0054\u0050 \u8fde\u63a5\u6a21\u5f0f\u65e0\u6548\uff1a%s", c.Notification.SMTPMode)
}
if c.Notification.SMTPEnabled {
if strings.TrimSpace(c.Notification.SMTPHost) == "" {
return fmt.Errorf("\u542f\u7528 SMTP \u901a\u77e5\u65f6\u5fc5\u987b\u914d\u7f6e SMTP \u4e3b\u673a")
}
if c.Notification.SMTPPort < 1 || c.Notification.SMTPPort > 65535 {
return fmt.Errorf("SMTP \u7aef\u53e3\u65e0\u6548\uff1a%d", c.Notification.SMTPPort)
}
if strings.TrimSpace(c.Notification.SMTPUsername) != "" && strings.TrimSpace(c.Notification.SMTPCredentialRef) == "" {
return fmt.Errorf("SMTP \u914d\u7f6e\u7528\u6237\u540d\u540e\u5fc5\u987b\u914d\u7f6e\u52a0\u5bc6\u51ed\u636e\u5f15\u7528")
}
}
if strings.TrimSpace(c.Notification.HTTPPath) == "" || !strings.HasPrefix(strings.TrimSpace(c.Notification.HTTPPath), "/") {
return fmt.Errorf("HTTP 通知请求路径必须以 / 开头")
}
if strings.TrimSpace(c.Notification.HTTPLoginPath) == "" || !strings.HasPrefix(strings.TrimSpace(c.Notification.HTTPLoginPath), "/") {
return fmt.Errorf("HTTP 通知登录请求路径必须以 / 开头")
}
if endpoint := strings.TrimSpace(c.Notification.Endpoint); endpoint != "" {
parsedEndpoint, endpointErr := url.Parse(endpoint)
if endpointErr != nil || (parsedEndpoint.Scheme != "http" && parsedEndpoint.Scheme != "https") || parsedEndpoint.Host == "" {
return fmt.Errorf("第三方邮箱 API 地址格式无效")
}
}
if c.Notification.SenderEmail != "" {
senderEmail := strings.ToLower(strings.TrimSpace(c.Notification.SenderEmail))
parsedSender, senderErr := mail.ParseAddress(senderEmail)
if senderErr != nil || parsedSender.Address != senderEmail || parsedSender.Name != "" {
return fmt.Errorf("通知发件人地址格式无效")
}
}
if c.Notification.TimeoutSecs < 1 || c.Notification.RetryCount < 0 || len(c.Notification.RetryIntervalsSecs) != c.Notification.RetryCount {
return fmt.Errorf("通知配置无效")
}
for _, interval := range c.Notification.RetryIntervalsSecs {
if interval < 1 {
return fmt.Errorf("通知重试间隔必须大于 0")
}
}
for header := range c.Notification.HTTPHeaders {
switch strings.ToLower(strings.TrimSpace(header)) {
case "authorization", "cookie", "origin", "referer", "content-type", "accept", "x-csrf-token", "x-scert-signature", "x-scert-request-id", "x-scert-timestamp":
return fmt.Errorf("HTTP 自定义请求头不允许覆盖安全头:%s", header)
}
}
return nil
}
// ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。
func (c Config) ResetURL() string {
rawHost := strings.TrimSpace(c.Web.Hostname)
if rawHost == "" {
rawHost = strings.TrimSpace(c.Web.PublicIP)
}
if rawHost == "" {
return ""
}
host, _ := splitResetHost(rawHost)
host = strings.Trim(host, "[]")
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
host = "[" + host + "]"
}
return (&url.URL{Scheme: "https", Host: host, Path: "/reset"}).String()
}
func splitResetHost(rawHost string) (string, string) {
trimmed := strings.TrimSpace(rawHost)
withoutBrackets := strings.Trim(trimmed, "[]")
if net.ParseIP(withoutBrackets) != nil {
return withoutBrackets, ""
}
parseTarget := trimmed
if !strings.Contains(parseTarget, "://") {
parseTarget = "//" + parseTarget
}
if parsed, err := url.Parse(parseTarget); err == nil && parsed.Hostname() != "" {
return parsed.Hostname(), parsed.Port()
}
if parsedHost, parsedPort, err := net.SplitHostPort(trimmed); err == nil {
return parsedHost, parsedPort
}
return withoutBrackets, ""
}