Files
nezha_domains/model/config.go
T

423 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"github.com/go-viper/mapstructure/v2"
kmaps "github.com/knadh/koanf/maps"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"sigs.k8s.io/yaml"
"github.com/nezhahq/nezha/pkg/utils"
)
// JWTSecretEnvKey is the canonical environment variable that injects the JWT
// signing key. When set, the dashboard never writes the key to disk and the
// version-driven rotation in RotateJWTSecretKeyIfNeeded is skipped so that
// rotation is fully controlled by the operator / KMS.
const JWTSecretEnvKey = "NZ_JWTSECRETKEY" // #nosec G101 -- environment variable name, not a hardcoded secret value.
const (
ConfigUsePeerIP = "NZ::Use-Peer-IP"
JWTSecretKeyRotationBaselineVersion = "v2.0.13"
)
const (
ConfigCoverAll = iota + 1
ConfigCoverIgnoreAll
)
type ConfigForGuests struct {
Language string `koanf:"language" json:"language"` // 系统语言,默认 zh_CN
SiteName string `koanf:"site_name" json:"site_name"`
CustomCode string `koanf:"custom_code" json:"custom_code,omitempty"`
CustomCodeDashboard string `koanf:"custom_code_dashboard" json:"custom_code_dashboard,omitempty"`
CustomLogo string `koanf:"custom_logo" json:"custom_logo,omitempty"`
CustomDescription string `koanf:"custom_description" json:"custom_description,omitempty"`
CustomLinks string `koanf:"custom_links" json:"custom_links,omitempty"`
BackgroundImageDay string `koanf:"background_image_day" json:"background_image_day,omitempty"`
BackgroundImageNight string `koanf:"background_image_night" json:"background_image_night,omitempty"`
}
type ConfigDashboard struct {
InstallHost string `koanf:"install_host" json:"install_host,omitempty"`
// AgentTLS controls the transport emitted by Agent installation commands.
// false intentionally supports trusted private networks and does not provide
// Dashboard peer authentication; Internet-facing control planes must use
// verified TLS. Changing this compatibility default belongs in the installer
// migration path, not in the gRPC task authorization model.
AgentTLS bool `koanf:"tls" json:"tls,omitempty"`
// DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。
// 它与 InstallHostagent 连接用主机名)解耦:两者可以是不同域名。
// 为空时,OAuth2 回调放行请求 Host(信任请求头),不做强制重写。
DashboardHost string `koanf:"dashboard_host" json:"dashboard_host,omitempty"`
WebRealIPHeader string `koanf:"web_real_ip_header" json:"web_real_ip_header,omitempty"` // 前端真实IP
AgentRealIPHeader string `koanf:"agent_real_ip_header" json:"agent_real_ip_header,omitempty"` // Agent真实IP
UserTemplate string `koanf:"user_template" json:"user_template,omitempty"`
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist
// GHSA-x6fg-52vr-hj4w:反代部署下 dashboard 的对外域名进程自身看不到,
// InstallHost/ListenHost 无法覆盖。运维在此用逗号分隔声明这些对外 host,
// 成员便无法注册与之冲突的 NAT 域名抢占路由。
ReservedHosts string `koanf:"reserved_hosts" json:"reserved_hosts,omitempty"`
// IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"`
TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"`
SMTPServer string `koanf:"smtp_server" json:"smtp_server,omitempty"`
SMTPUser string `koanf:"smtp_user" json:"smtp_user,omitempty"`
SMTPPassword string `koanf:"smtp_password" json:"smtp_password,omitempty"`
AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"`
DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"`
ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"`
}
type Config struct {
ConfigForGuests
ConfigDashboard
AvgPingCount int `koanf:"avg_ping_count" json:"avg_ping_count,omitempty"`
Debug bool `koanf:"debug" json:"debug,omitempty"` // debug模式开关
Location string `koanf:"location" json:"location,omitempty"` // 时区,默认为 Asia/Shanghai
ForceAuth bool `koanf:"force_auth" json:"force_auth,omitempty"` // 强制要求认证
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
JWTSecretKey string `koanf:"jwt_secret_key" json:"-" yaml:"-"`
JWTSecretKeyLastRotatedVersion string `koanf:"jwt_secret_key_last_rotated_version" json:"jwt_secret_key_last_rotated_version,omitempty"`
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
jwtSecretFromEnv bool `koanf:"-" json:"-" yaml:"-"`
jwtSecretFromYAML bool `koanf:"-" json:"-" yaml:"-"`
// mcpEnabledEnableMCP 的并发安全镜像,kill switch 跨 goroutine 读写走
// MCPEnabled()/SetMCPEnabled()。放外层 Config 而非 ConfigDashboard,避免
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"`
// oauth2 配置
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
// HTTPS 配置
HTTPS HTTPSConf `koanf:"https" json:"https"`
// TSDB 配置
TSDB TSDBConf `koanf:"tsdb" json:"tsdb"`
// 内存配置
Memory MemoryConf `koanf:"memory" json:"memory"`
k *koanf.Koanf `json:"-"`
filePath string `json:"-"`
}
type HTTPSConf struct {
InsecureTLS bool `koanf:"insecure_tls" json:"insecure_tls,omitempty"`
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
TLSCertPath string `koanf:"tls_cert_path" json:"tls_cert_path,omitempty"`
TLSKeyPath string `koanf:"tls_key_path" json:"tls_key_path,omitempty"`
}
// TSDBConf TSDB 配置
type TSDBConf struct {
DataPath string `koanf:"data_path" json:"data_path,omitempty"`
RetentionDays uint16 `koanf:"retention_days" json:"retention_days,omitempty"`
MinFreeDiskSpaceGB float64 `koanf:"min_free_disk_space_gb" json:"min_free_disk_space_gb,omitempty"`
MaxMemoryMB int64 `koanf:"max_memory_mb" json:"max_memory_mb,omitempty"`
WriteBufferSize int `koanf:"write_buffer_size" json:"write_buffer_size,omitempty"`
WriteBufferFlushInterval int `koanf:"write_buffer_flush_interval" json:"write_buffer_flush_interval,omitempty"`
}
// MemoryConf 内存配置
type MemoryConf struct {
// GoMemLimitMB Go 运行时内存限制(MB),0 表示不限制
GoMemLimitMB int64 `koanf:"go_mem_limit_mb" json:"go_mem_limit_mb,omitempty"`
}
// Read 读取配置文件并应用
func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
c.k = koanf.New(".")
c.filePath = path
err := c.k.Load(env.Provider("NZ_", ".", func(s string) string {
return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, "NZ_")), "_", ".")
}), nil)
if err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
err = c.k.Load(file.Provider(path), new(utils.KubeYAML), koanf.WithMergeFunc(mergeDedup))
if err != nil {
return err
}
}
err = c.k.UnmarshalWithConf("", c, koanfConf(c))
if err != nil {
return err
}
if c.ListenPort == 0 {
c.ListenPort = 8008
}
if c.Language == "" {
c.Language = "en_US"
}
if c.Location == "" {
c.Location = "Asia/Shanghai"
}
var userTemplateValid, adminTemplateValid bool
for _, v := range frontendTemplates {
if !userTemplateValid && v.Path == c.UserTemplate && !v.IsAdmin {
userTemplateValid = true
}
if !adminTemplateValid && v.Path == c.AdminTemplate && v.IsAdmin {
adminTemplateValid = true
}
if userTemplateValid && adminTemplateValid {
break
}
}
if c.UserTemplate == "" || !userTemplateValid {
c.UserTemplate = "user-dist"
}
if c.AdminTemplate == "" || !adminTemplateValid {
c.AdminTemplate = "admin-dist"
}
if c.AvgPingCount == 0 {
c.AvgPingCount = 2
}
if c.Cover == 0 {
c.Cover = 1
}
if envSecret := os.Getenv(JWTSecretEnvKey); envSecret != "" {
c.JWTSecretKey = envSecret
c.jwtSecretFromEnv = true
} else if c.JWTSecretKey != "" {
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> jwt_secret_key loaded from config.yaml; recommend injecting via env %s to keep it off disk", JWTSecretEnvKey)
}
if c.JWTSecretKey == "" {
generated, err := utils.GenerateRandomString(1024)
if err != nil {
return err
}
c.JWTSecretKey = generated
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> generated new jwt_secret_key; wrote to config.yaml. For production, inject via env %s and remove the field from config.yaml.", JWTSecretEnvKey)
if err := c.patchYAMLField("jwt_secret_key", generated); err != nil {
return err
}
}
// Add JWTTimeout default check
if c.JWTTimeout == 0 {
c.JWTTimeout = 1
}
if c.AgentSecretKey == "" {
c.AgentSecretKey, err = utils.GenerateRandomString(32)
if err != nil {
return err
}
if err = c.Save(); err != nil {
return err
}
}
c.mcpEnabled.Store(c.EnableMCP)
return nil
}
// MCPEnabled 并发安全地读取 MCP kill switch 状态。
func (c *Config) MCPEnabled() bool {
return c.mcpEnabled.Load()
}
// SetMCPEnabled 并发安全地更新 MCP kill switch 状态。只写 atomic 镜像,不直接
// 写 EnableMCP 明文字段——后者会与 listConfig 的 *singleton.Conf 整体拷贝读发生
// 数据竞争。持久化由 save() 在 marshal 前从 atomic 同步明文字段完成。
func (c *Config) SetMCPEnabled(v bool) {
c.mcpEnabled.Store(v)
}
// Save 保存配置文件
func (c *Config) Save() error {
return c.save()
}
func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) {
if c.jwtSecretFromEnv {
return false, nil
}
currentVersion = strings.TrimSpace(currentVersion)
if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 {
return false, nil
}
initialMarker := c.JWTSecretKeyLastRotatedVersion
shouldRotate := c.JWTSecretKeyLastRotatedVersion == "" || compareVersion(c.JWTSecretKeyLastRotatedVersion, JWTSecretKeyRotationBaselineVersion) < 0
if shouldRotate {
secret, err := utils.GenerateRandomString(1024)
if err != nil {
return false, err
}
c.JWTSecretKey = secret
}
c.JWTSecretKeyLastRotatedVersion = currentVersion
if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker {
return false, nil
}
if shouldRotate {
if err := c.patchYAMLField("jwt_secret_key", c.JWTSecretKey); err != nil {
return false, err
}
}
if err := c.patchYAMLField("jwt_secret_key_last_rotated_version", c.JWTSecretKeyLastRotatedVersion); err != nil {
return false, err
}
return shouldRotate, nil
}
func (c *Config) patchYAMLField(key string, value any) error {
dir := filepath.Dir(c.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
raw := map[string]any{}
if data, err := os.ReadFile(c.filePath); err == nil {
if len(data) > 0 {
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
}
} else if !os.IsNotExist(err) {
return err
}
raw[key] = value
out, err := yaml.Marshal(raw)
if err != nil {
return err
}
return os.WriteFile(c.filePath, out, 0600)
}
func (c *Config) save() error {
c.EnableMCP = c.mcpEnabled.Load()
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return c.write(data)
}
func (c *Config) write(data []byte) error {
dir := filepath.Dir(c.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
return os.WriteFile(c.filePath, data, 0600)
}
func compareVersion(left, right string) int {
leftParts, leftOK := parseVersion(left)
rightParts, rightOK := parseVersion(right)
if !leftOK || !rightOK {
return -1
}
for i := range leftParts {
if leftParts[i] < rightParts[i] {
return -1
}
if leftParts[i] > rightParts[i] {
return 1
}
}
return 0
}
func parseVersion(version string) ([3]int, bool) {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var parsed [3]int
for i, part := range parts {
value, err := strconv.Atoi(part)
if err != nil {
return [3]int{}, false
}
parsed[i] = value
}
return parsed, true
}
func koanfConf(c any) koanf.UnmarshalConf {
return koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
utils.TextUnmarshalerHookFunc()),
Metadata: nil,
Result: c,
WeaklyTypedInput: true,
MatchName: func(mapKey, fieldName string) bool {
return strings.EqualFold(mapKey, fieldName) ||
strings.EqualFold(mapKey, strings.ReplaceAll(fieldName, "_", ""))
},
Squash: true,
},
}
}
func mergeDedup(src, dst map[string]any) error {
for key := range src {
if strings.IndexByte(key, '_') == -1 {
continue
}
oldKey := strings.ReplaceAll(key, "_", "")
if _, ok := dst[oldKey]; ok {
src[oldKey] = src[key]
delete(src, key)
}
}
kmaps.Merge(src, dst)
return nil
}