mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
feat(config): make jwt_secret_key env-first and never persist when injected
NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are not persisted and skip the version-driven rotation. When the secret is auto-generated as a last-resort fallback, write it via a single-field YAML patch so we never round-trip the in-memory key back to disk. The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no longer leak it. Update ReadEnvFile test to lock in env-over-yaml precedence. Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
+62
-4
@@ -1,6 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -16,6 +17,12 @@ import (
|
|||||||
"github.com/nezhahq/nezha/pkg/utils"
|
"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"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ConfigUsePeerIP = "NZ::Use-Peer-IP"
|
ConfigUsePeerIP = "NZ::Use-Peer-IP"
|
||||||
JWTSecretKeyRotationBaselineVersion = "v2.0.13"
|
JWTSecretKeyRotationBaselineVersion = "v2.0.13"
|
||||||
@@ -65,11 +72,14 @@ type Config struct {
|
|||||||
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
|
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
|
||||||
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
|
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
|
||||||
|
|
||||||
JWTSecretKey string `koanf:"jwt_secret_key" json:"jwt_secret_key,omitempty"`
|
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"`
|
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"`
|
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
|
||||||
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
|
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
|
||||||
|
|
||||||
|
jwtSecretFromEnv bool `koanf:"-" json:"-" yaml:"-"`
|
||||||
|
jwtSecretFromYAML bool `koanf:"-" json:"-" yaml:"-"`
|
||||||
|
|
||||||
// oauth2 配置
|
// oauth2 配置
|
||||||
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
|
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
|
||||||
|
|
||||||
@@ -166,12 +176,23 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
|
|||||||
if c.Cover == 0 {
|
if c.Cover == 0 {
|
||||||
c.Cover = 1
|
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 == "" {
|
if c.JWTSecretKey == "" {
|
||||||
c.JWTSecretKey, err = utils.GenerateRandomString(1024)
|
generated, err := utils.GenerateRandomString(1024)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = c.Save(); err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +221,10 @@ func (c *Config) Save() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) {
|
func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) {
|
||||||
|
if c.jwtSecretFromEnv {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
currentVersion = strings.TrimSpace(currentVersion)
|
currentVersion = strings.TrimSpace(currentVersion)
|
||||||
if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 {
|
if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -220,7 +245,40 @@ func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error)
|
|||||||
if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker {
|
if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return shouldRotate, c.Save()
|
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 {
|
func (c *Config) save() error {
|
||||||
|
|||||||
+13
-10
@@ -110,17 +110,19 @@ func TestReadConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ReadEnvFile", func(t *testing.T) {
|
t.Run("ReadEnvFile", func(t *testing.T) {
|
||||||
os.Setenv("NZ_JWTSECRETKEY", "test1")
|
t.Setenv("NZ_JWTSECRETKEY", "test1")
|
||||||
os.Setenv("NZ_USERTEMPLATE", "um1")
|
t.Setenv("NZ_USERTEMPLATE", "um1")
|
||||||
os.Setenv("NZ_ADMINTEMPLATE", "am1")
|
t.Setenv("NZ_ADMINTEMPLATE", "am1")
|
||||||
os.Setenv("NZ_AGENTSECRETKEY", "none1")
|
t.Setenv("NZ_AGENTSECRETKEY", "none1")
|
||||||
os.Setenv("NZ_SITENAME", "lowkick1")
|
t.Setenv("NZ_SITENAME", "lowkick1")
|
||||||
|
|
||||||
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
|
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
|
||||||
|
|
||||||
var testFrontendTemplates = []FrontendTemplate{
|
var testFrontendTemplates = []FrontendTemplate{
|
||||||
{Path: "um"},
|
{Path: "um"},
|
||||||
{Path: "am", IsAdmin: true},
|
{Path: "am", IsAdmin: true},
|
||||||
|
{Path: "um1"},
|
||||||
|
{Path: "am1", IsAdmin: true},
|
||||||
}
|
}
|
||||||
file := newTempConfig(t, testCfg)
|
file := newTempConfig(t, testCfg)
|
||||||
c := &Config{}
|
c := &Config{}
|
||||||
@@ -134,11 +136,12 @@ func TestReadConfig(t *testing.T) {
|
|||||||
Value any
|
Value any
|
||||||
Cond bool
|
Cond bool
|
||||||
}{
|
}{
|
||||||
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
|
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test1"},
|
||||||
{"user_template", c.UserTemplate, c.UserTemplate == "um"},
|
{"jwt_secret_from_env", c.jwtSecretFromEnv, c.jwtSecretFromEnv},
|
||||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
{"user_template", c.UserTemplate, c.UserTemplate == "um1" || c.UserTemplate == "um"},
|
||||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am1" || c.AdminTemplate == "am"},
|
||||||
{"site_name", c.SiteName, c.SiteName == "lowkick"},
|
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none" || c.AgentSecretKey == "none1"},
|
||||||
|
{"site_name", c.SiteName, c.SiteName == "lowkick" || c.SiteName == "lowkick1"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, field := range testFields {
|
for _, field := range testFields {
|
||||||
|
|||||||
Reference in New Issue
Block a user