From ad1282e0a936c7ee4d561e5fc10187a118f0fc26 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:18:19 +0800 Subject: [PATCH 01/10] fix: require secure session signing secret --- .../dubbo-admin-oauth-example.yaml | 3 +- app/dubbo-admin/dubbo-admin.yaml | 11 +++-- docs/server-develop.md | 1 + pkg/config/console/auth/config.go | 13 ++++-- pkg/config/console/auth/config_test.go | 41 +++++++++++++++++-- pkg/config/console/config.go | 5 --- pkg/config/console/config_test.go | 19 +++++++-- .../kubernetes/dubbo-system/dubbo-admin.yaml | 19 ++++++--- 8 files changed, 86 insertions(+), 26 deletions(-) diff --git a/app/dubbo-admin/dubbo-admin-oauth-example.yaml b/app/dubbo-admin/dubbo-admin-oauth-example.yaml index 4b600a2ec..9b1cfceb1 100644 --- a/app/dubbo-admin/dubbo-admin-oauth-example.yaml +++ b/app/dubbo-admin/dubbo-admin-oauth-example.yaml @@ -23,7 +23,8 @@ console: user: admin password: replace-with-admin-password expirationTime: 3600 - sessionSecret: replace-with-a-random-secret-at-least-32-bytes + # Required. Generate a unique value with: openssl rand -base64 32 + sessionSecret: "" sessionCookieSecure: true providers: github: diff --git a/app/dubbo-admin/dubbo-admin.yaml b/app/dubbo-admin/dubbo-admin.yaml index a9588337e..a17ea2bbb 100644 --- a/app/dubbo-admin/dubbo-admin.yaml +++ b/app/dubbo-admin/dubbo-admin.yaml @@ -50,10 +50,13 @@ console: instance: http://101.34.253.152:30300/d/f5f48f75-13ec-489b-88ae-635ae38d8618?kiosk=1&theme=light service: http://101.34.253.152:30300/d/b2e178fb-ada3-4d5e-9f54-de99e7f07662?kiosk=1&theme=light # [Optional] typical user-pwd authentication, default user-pwd is admin-admin. - auth: - user: admin - password: dubbo@2025 - expirationTime: 3600 + auth: + user: admin + password: dubbo@2025 + # Required. Set a deployment-specific random value, for example: + # openssl rand -base64 32 + sessionSecret: "" + expirationTime: 3600 # [Optional] config for data storage, default is memory store: diff --git a/docs/server-develop.md b/docs/server-develop.md index 0c71ef67d..bbef9accd 100644 --- a/docs/server-develop.md +++ b/docs/server-develop.md @@ -43,6 +43,7 @@ If you're using GoLand, you can run it locally by following steps: 2. Fill the block with the config that screenshot shows below: ![ide_configuration.png](./static/images/ide-config.png) 3. Modify the config file(app/dubbo-admin/dubbo-admin.yaml), make sure that the discovery, engine, store is configured. + Configure `console.auth.sessionSecret` with a unique random value before starting the server. For deployments that use a secret manager or Kubernetes Secret, the value can instead be provided through `DUBBO_ADMIN_SESSION_SECRET`. Generate a value with `openssl rand -base64 32`; the server refuses to start when the value is missing or shorter than 32 bytes. Traffic-rule version history records RuleVersion audit entries for history, diff, and rollback material. Supported governance rule mutations fail closed if the version ledger is unavailable or cannot record the mutation. 4. Run the application, you can open the browser and visit localhost:8888/admin if everything works. diff --git a/pkg/config/console/auth/config.go b/pkg/config/console/auth/config.go index fd16d9b9d..c0e91e5f4 100644 --- a/pkg/config/console/auth/config.go +++ b/pkg/config/console/auth/config.go @@ -22,6 +22,7 @@ import ( "fmt" "net" "net/url" + "os" "regexp" "slices" "strings" @@ -30,8 +31,9 @@ import ( ) const ( - DefaultExpirationTime = 7200 - DefaultSessionSecret = "secret" + DefaultExpirationTime = 7200 + MinimumSessionSecretLength = 32 + SessionSecretEnvVar = "DUBBO_ADMIN_SESSION_SECRET" MethodPassword = "password" ProviderTypeGitHub = "github" @@ -89,8 +91,11 @@ func (c *Config) Validate() error { if c.ExpirationTime <= 0 || c.ExpirationTime >= 24*60*60 { return errors.New("auth: expirationTime should be greater than 0 and less than 86400") } - if c.SessionSecret == "" { - c.SessionSecret = DefaultSessionSecret + if strings.TrimSpace(c.SessionSecret) == "" { + c.SessionSecret = os.Getenv(SessionSecretEnvVar) + } + if strings.TrimSpace(c.SessionSecret) == "" || len([]byte(c.SessionSecret)) < MinimumSessionSecretLength { + return fmt.Errorf("auth: sessionSecret must contain at least %d bytes", MinimumSessionSecretLength) } for id, provider := range c.Providers { if err := validateProvider(id, &provider); err != nil { diff --git a/pkg/config/console/auth/config_test.go b/pkg/config/console/auth/config_test.go index 6ed1005fa..00076ae22 100644 --- a/pkg/config/console/auth/config_test.go +++ b/pkg/config/console/auth/config_test.go @@ -23,10 +23,15 @@ import ( ) func validConfig() *Config { - return &Config{User: "admin", Password: "secret", ExpirationTime: 3600} + return &Config{ + User: "admin", + Password: "secret", + ExpirationTime: 3600, + SessionSecret: strings.Repeat("s", MinimumSessionSecretLength), + } } -func TestConfigValidateDefaultsPasswordOnly(t *testing.T) { +func TestConfigValidatePasswordOnly(t *testing.T) { cfg := validConfig() if err := cfg.Validate(); err != nil { t.Fatalf("Validate() error = %v", err) @@ -34,8 +39,36 @@ func TestConfigValidateDefaultsPasswordOnly(t *testing.T) { if len(cfg.Methods) != 1 || cfg.Methods[0] != MethodPassword { t.Fatalf("Methods = %v, want [%s]", cfg.Methods, MethodPassword) } - if cfg.SessionSecret != DefaultSessionSecret { - t.Fatalf("SessionSecret = %q, want legacy default", cfg.SessionSecret) + if cfg.SessionSecret != strings.Repeat("s", MinimumSessionSecretLength) { + t.Fatalf("SessionSecret = %q, want configured secret", cfg.SessionSecret) + } +} + +func TestConfigValidateRequiresSessionSecret(t *testing.T) { + cfg := validConfig() + cfg.SessionSecret = "" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "sessionSecret") { + t.Fatalf("Validate() error = %v, want missing sessionSecret error", err) + } +} + +func TestConfigValidateLoadsSessionSecretFromEnvironment(t *testing.T) { + t.Setenv(SessionSecretEnvVar, strings.Repeat("e", MinimumSessionSecretLength)) + cfg := validConfig() + cfg.SessionSecret = "" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.SessionSecret != strings.Repeat("e", MinimumSessionSecretLength) { + t.Fatalf("SessionSecret = %q, want environment value", cfg.SessionSecret) + } +} + +func TestConfigValidateRejectsShortSessionSecret(t *testing.T) { + cfg := validConfig() + cfg.SessionSecret = "short" + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "32 bytes") { + t.Fatalf("Validate() error = %v, want minimum sessionSecret length error", err) } } diff --git a/pkg/config/console/config.go b/pkg/config/console/config.go index 0c04703da..80556984c 100644 --- a/pkg/config/console/config.go +++ b/pkg/config/console/config.go @@ -78,10 +78,6 @@ func (c *Config) Validate() error { if err := c.Auth.Validate(); err != nil { return err } - // Release deployments with external providers must reject the legacy default session secret and other short cookie-signing keys. - if c.GinMode == ReleaseMode && len(c.Auth.Providers) > 0 && len([]byte(c.Auth.SessionSecret)) < 32 { - return bizerror.New(bizerror.ConfigError, "auth sessionSecret must contain at least 32 bytes when providers are enabled in release mode") - } return nil } @@ -136,7 +132,6 @@ func DefaultConsoleConfig() *Config { User: "admin", Password: "admin", ExpirationTime: 3600, - SessionSecret: auth.DefaultSessionSecret, }, } } diff --git a/pkg/config/console/config_test.go b/pkg/config/console/config_test.go index 34469db2a..ba8858d96 100644 --- a/pkg/config/console/config_test.go +++ b/pkg/config/console/config_test.go @@ -43,6 +43,18 @@ func TestReleaseProviderRequiresStrongSessionSecret(t *testing.T) { } } +func TestPasswordAuthenticationRequiresStrongSessionSecret(t *testing.T) { + cfg := DefaultConsoleConfig() + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "sessionSecret") { + t.Fatalf("Validate() error = %v, want sessionSecret error", err) + } + + cfg.Auth.SessionSecret = "a-long-deployment-specific-session-secret" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() with strong secret error = %v", err) + } +} + func TestReleaseProviderRejectsShortSessionSecret(t *testing.T) { cfg := DefaultConsoleConfig() cfg.Auth.Providers = map[string]auth.ProviderConfig{ @@ -57,16 +69,17 @@ func TestReleaseProviderRejectsShortSessionSecret(t *testing.T) { } } -func TestDebugProviderAllowsLegacySessionSecret(t *testing.T) { +func TestDebugProviderRejectsShortSessionSecret(t *testing.T) { cfg := DefaultConsoleConfig() cfg.GinMode = DebugMode + cfg.Auth.SessionSecret = "short" cfg.Auth.Providers = map[string]auth.ProviderConfig{ "github": { Type: auth.ProviderTypeGitHub, ClientID: "id", ClientSecret: "secret", RedirectURL: "http://localhost:8888/api/v1/auth/providers/github/callback", PostLoginRedirectURL: "http://localhost:8881/admin/", }, } - if err := cfg.Validate(); err != nil { - t.Fatalf("Validate() error = %v", err) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "32 bytes") { + t.Fatalf("Validate() error = %v, want minimum sessionSecret length error", err) } } diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml index 517cdfeb4..ddf64d8a5 100644 --- a/release/kubernetes/dubbo-system/dubbo-admin.yaml +++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml @@ -12,8 +12,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - -apiVersion: apps/v1 +# Create the session secret before applying this manifest: +# kubectl -n dubbo-system create secret generic dubbo-admin-auth --from-literal=session-secret="$(openssl rand -base64 32)" + +apiVersion: apps/v1 kind: Deployment metadata: name: dubbo-admin @@ -39,9 +41,15 @@ spec: serviceAccountName: dubbo-admin containers: - name: dubbo-admin - image: apache/dubbo-admin:0.7.0 - imagePullPolicy: IfNotPresent - volumeMounts: + image: apache/dubbo-admin:0.7.0 + imagePullPolicy: IfNotPresent + env: + - name: DUBBO_ADMIN_SESSION_SECRET + valueFrom: + secretKeyRef: + name: dubbo-admin-auth + key: session-secret + volumeMounts: - mountPath: /etc/dubbo-admin name: dubbo-admin-config readOnly: true @@ -100,6 +108,7 @@ data: auth: user: admin password: dubbo@2025 + # sessionSecret is injected through DUBBO_ADMIN_SESSION_SECRET. expirationTime: 3600 ruleVersioning: maxVersionsPerRule: 20 From f04a5193403933a984bbb6915dd054e09329d0b8 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:05:36 +0800 Subject: [PATCH 02/10] fix: validate default console session secret --- pkg/config/app/admin.go | 17 +++++++++++++---- pkg/config/app/admin_test.go | 23 +++++++++++++++++++++++ pkg/config/console/auth/config_test.go | 1 + pkg/config/console/config_test.go | 2 ++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go index c2c05f295..c054d857a 100644 --- a/pkg/config/app/admin.go +++ b/pkg/config/app/admin.go @@ -79,7 +79,9 @@ func (c *AdminConfig) Sanitize() { d.Sanitize() } c.Store.Sanitize() - c.Console.Sanitize() + if c.Console != nil { + c.Console.Sanitize() + } c.Observability.Sanitize() c.Diagnostics.Sanitize() c.Log.Sanitize() @@ -90,6 +92,7 @@ func (c *AdminConfig) Sanitize() { } func (c *AdminConfig) PreProcess() error { + c.ensureConsoleConfig() discoveryPreProcess := func() error { for _, d := range c.Discovery { if err := d.PreProcess(); err != nil { @@ -114,6 +117,7 @@ func (c *AdminConfig) PreProcess() error { } func (c *AdminConfig) PostProcess() error { + c.ensureConsoleConfig() discoveryPostProcess := func() error { for _, d := range c.Discovery { if err := d.PostProcess(); err != nil { @@ -153,9 +157,8 @@ func (c *AdminConfig) Validate() error { } else if err := c.Diagnostics.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "diagnostics config validation failed") } - if c.Console == nil { - c.Console = console.DefaultConsoleConfig() - } else if err := c.Console.Validate(); err != nil { + c.ensureConsoleConfig() + if err := c.Console.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "console config validation failed") } if c.Observability == nil { @@ -196,6 +199,12 @@ func (c *AdminConfig) Validate() error { return nil } +func (c *AdminConfig) ensureConsoleConfig() { + if c.Console == nil { + c.Console = console.DefaultConsoleConfig() + } +} + // FindDiscovery finds the DiscoveryConfig by id, returns nil if not found func (c *AdminConfig) FindDiscovery(id string) *discovery.Config { for _, d := range c.Discovery { diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go index ceaf84d89..383dd2949 100644 --- a/pkg/config/app/admin_test.go +++ b/pkg/config/app/admin_test.go @@ -18,11 +18,15 @@ package app import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/apache/dubbo-admin/pkg/config" + configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" "github.com/apache/dubbo-admin/pkg/config/versioning" ) @@ -35,3 +39,22 @@ func TestAdminConfigSanitizeRetainsDefaultRuleVersioning(t *testing.T) { require.NotNil(t, cfg.RuleVersioning) assert.Equal(t, versioning.DefaultMaxVersionsPerRule, cfg.RuleVersioning.MaxVersionsPerRule) } + +func TestAdminConfigValidateRejectsMissingSecretInDefaultConsole(t *testing.T) { + t.Setenv(configauth.SessionSecretEnvVar, "") + cfg := DefaultAdminConfig() + cfg.Console = nil + + err := cfg.Validate() + require.ErrorContains(t, err, "sessionSecret") +} + +func TestConfigLoadRejectsNullConsoleWithoutPanicking(t *testing.T) { + t.Setenv(configauth.SessionSecretEnvVar, "") + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("console: null\n"), 0600)) + + cfg := DefaultAdminConfig() + err := config.Load(path, &cfg) + require.ErrorContains(t, err, "sessionSecret") +} diff --git a/pkg/config/console/auth/config_test.go b/pkg/config/console/auth/config_test.go index 00076ae22..f795d7bbf 100644 --- a/pkg/config/console/auth/config_test.go +++ b/pkg/config/console/auth/config_test.go @@ -45,6 +45,7 @@ func TestConfigValidatePasswordOnly(t *testing.T) { } func TestConfigValidateRequiresSessionSecret(t *testing.T) { + t.Setenv(SessionSecretEnvVar, "") cfg := validConfig() cfg.SessionSecret = "" if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "sessionSecret") { diff --git a/pkg/config/console/config_test.go b/pkg/config/console/config_test.go index ba8858d96..2ce5fdbbc 100644 --- a/pkg/config/console/config_test.go +++ b/pkg/config/console/config_test.go @@ -25,6 +25,7 @@ import ( ) func TestReleaseProviderRequiresStrongSessionSecret(t *testing.T) { + t.Setenv(auth.SessionSecretEnvVar, "") cfg := DefaultConsoleConfig() cfg.Auth.Providers = map[string]auth.ProviderConfig{ "github": { @@ -44,6 +45,7 @@ func TestReleaseProviderRequiresStrongSessionSecret(t *testing.T) { } func TestPasswordAuthenticationRequiresStrongSessionSecret(t *testing.T) { + t.Setenv(auth.SessionSecretEnvVar, "") cfg := DefaultConsoleConfig() if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "sessionSecret") { t.Fatalf("Validate() error = %v, want sessionSecret error", err) From 0a1b8e9e52790b2c97e1bfbf599a4db27d888786 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:15:23 +0800 Subject: [PATCH 03/10] fix: reject missing console configuration --- pkg/config/app/admin.go | 19 +++++++++---------- pkg/config/app/admin_test.go | 32 ++++++++++++++++++++++---------- pkg/config/display_test.go | 2 ++ 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/pkg/config/app/admin.go b/pkg/config/app/admin.go index c054d857a..fcfb071c6 100644 --- a/pkg/config/app/admin.go +++ b/pkg/config/app/admin.go @@ -67,7 +67,6 @@ var DefaultAdminConfig = func() AdminConfig { Engine: engine.DefaultResourceEngineConfig(), Observability: observability.DefaultObservabilityConfig(), Diagnostics: diagnostics.DefaultDiagnosticsConfig(), - Console: console.DefaultConsoleConfig(), EventBus: &eventBusCfg, RuleVersioning: versioning.Default(), } @@ -92,7 +91,9 @@ func (c *AdminConfig) Sanitize() { } func (c *AdminConfig) PreProcess() error { - c.ensureConsoleConfig() + if c.Console == nil { + return bizerror.New(bizerror.ConfigError, "console config is needed, but found empty") + } discoveryPreProcess := func() error { for _, d := range c.Discovery { if err := d.PreProcess(); err != nil { @@ -117,7 +118,9 @@ func (c *AdminConfig) PreProcess() error { } func (c *AdminConfig) PostProcess() error { - c.ensureConsoleConfig() + if c.Console == nil { + return bizerror.New(bizerror.ConfigError, "console config is needed, but found empty") + } discoveryPostProcess := func() error { for _, d := range c.Discovery { if err := d.PostProcess(); err != nil { @@ -142,6 +145,9 @@ func (c *AdminConfig) PostProcess() error { } func (c *AdminConfig) Validate() error { + if c.Console == nil { + return bizerror.New(bizerror.ConfigError, "console config is needed, but found empty") + } if c.Log == nil { c.Log = log.DefaultLogConfig() } else if err := c.Log.Validate(); err != nil { @@ -157,7 +163,6 @@ func (c *AdminConfig) Validate() error { } else if err := c.Diagnostics.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "diagnostics config validation failed") } - c.ensureConsoleConfig() if err := c.Console.Validate(); err != nil { return bizerror.Wrap(err, bizerror.ConfigError, "console config validation failed") } @@ -199,12 +204,6 @@ func (c *AdminConfig) Validate() error { return nil } -func (c *AdminConfig) ensureConsoleConfig() { - if c.Console == nil { - c.Console = console.DefaultConsoleConfig() - } -} - // FindDiscovery finds the DiscoveryConfig by id, returns nil if not found func (c *AdminConfig) FindDiscovery(id string) *discovery.Config { for _, d := range c.Discovery { diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go index 383dd2949..1f04b521d 100644 --- a/pkg/config/app/admin_test.go +++ b/pkg/config/app/admin_test.go @@ -40,21 +40,33 @@ func TestAdminConfigSanitizeRetainsDefaultRuleVersioning(t *testing.T) { assert.Equal(t, versioning.DefaultMaxVersionsPerRule, cfg.RuleVersioning.MaxVersionsPerRule) } -func TestAdminConfigValidateRejectsMissingSecretInDefaultConsole(t *testing.T) { +func TestAdminConfigValidateRejectsMissingConsole(t *testing.T) { t.Setenv(configauth.SessionSecretEnvVar, "") cfg := DefaultAdminConfig() - cfg.Console = nil err := cfg.Validate() - require.ErrorContains(t, err, "sessionSecret") + require.ErrorContains(t, err, "console config is needed") } -func TestConfigLoadRejectsNullConsoleWithoutPanicking(t *testing.T) { - t.Setenv(configauth.SessionSecretEnvVar, "") - path := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(path, []byte("console: null\n"), 0600)) +func TestConfigLoadFailsClosedForMissingOrEmptyConsole(t *testing.T) { + t.Setenv(configauth.SessionSecretEnvVar, "0123456789abcdef0123456789abcdef") + tests := []struct { + name string + contents string + wantError string + }{ + {name: "missing", contents: "discovery: []\n", wantError: "console config is needed"}, + {name: "null", contents: "console: null\n", wantError: "console config is needed"}, + {name: "empty object", contents: "console: {}\n", wantError: "invalid gin mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(tt.contents), 0600)) - cfg := DefaultAdminConfig() - err := config.Load(path, &cfg) - require.ErrorContains(t, err, "sessionSecret") + cfg := DefaultAdminConfig() + err := config.Load(path, &cfg) + require.ErrorContains(t, err, tt.wantError) + }) + } } diff --git a/pkg/config/display_test.go b/pkg/config/display_test.go index 4694dbe8f..84da710f9 100644 --- a/pkg/config/display_test.go +++ b/pkg/config/display_test.go @@ -22,11 +22,13 @@ import ( "github.com/apache/dubbo-admin/pkg/config" "github.com/apache/dubbo-admin/pkg/config/app" + "github.com/apache/dubbo-admin/pkg/config/console" configauth "github.com/apache/dubbo-admin/pkg/config/console/auth" ) func TestConfigForDisplaySanitizesConsoleAuthenticationSecrets(t *testing.T) { cfg := app.DefaultAdminConfig() + cfg.Console = console.DefaultConsoleConfig() cfg.Console.Auth.Password = "password-secret" cfg.Console.Auth.SessionSecret = "session-secret" cfg.Console.Auth.Providers = map[string]configauth.ProviderConfig{ From 16af62fb5b4af97f6bb512850bf98177fd7c4a91 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:19:55 +0800 Subject: [PATCH 04/10] test: cover fail-closed console loading --- pkg/config/app/admin_test.go | 49 +++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/pkg/config/app/admin_test.go b/pkg/config/app/admin_test.go index 1f04b521d..a196aa2f3 100644 --- a/pkg/config/app/admin_test.go +++ b/pkg/config/app/admin_test.go @@ -41,7 +41,7 @@ func TestAdminConfigSanitizeRetainsDefaultRuleVersioning(t *testing.T) { } func TestAdminConfigValidateRejectsMissingConsole(t *testing.T) { - t.Setenv(configauth.SessionSecretEnvVar, "") + t.Setenv(configauth.SessionSecretEnvVar, "0123456789abcdef0123456789abcdef") cfg := DefaultAdminConfig() err := cfg.Validate() @@ -70,3 +70,50 @@ func TestConfigLoadFailsClosedForMissingOrEmptyConsole(t *testing.T) { }) } } + +func TestConfigLoadUsesSessionSecretFromEnvironment(t *testing.T) { + secret := "0123456789abcdef0123456789abcdef" + t.Setenv(configauth.SessionSecretEnvVar, secret) + path := filepath.Join(t.TempDir(), "config.yaml") + contents := `console: + ginMode: release + port: 8888 + auth: + user: admin + password: test-password + expirationTime: 3600 + sessionSecret: "" +discovery: + - id: test + name: test + type: mock +` + require.NoError(t, os.WriteFile(path, []byte(contents), 0600)) + + cfg := DefaultAdminConfig() + require.NoError(t, config.Load(path, &cfg)) + assert.Equal(t, secret, cfg.Console.Auth.SessionSecret) +} + +func TestConfigLoadRejectsMissingSessionSecret(t *testing.T) { + t.Setenv(configauth.SessionSecretEnvVar, "") + path := filepath.Join(t.TempDir(), "config.yaml") + contents := `console: + ginMode: release + port: 8888 + auth: + user: admin + password: test-password + expirationTime: 3600 + sessionSecret: "" +discovery: + - id: test + name: test + type: mock +` + require.NoError(t, os.WriteFile(path, []byte(contents), 0600)) + + cfg := DefaultAdminConfig() + err := config.Load(path, &cfg) + require.ErrorContains(t, err, "sessionSecret") +} From 41b9aa1b7d0c500e9e9f13fb3a1f5e8a6ec2eb71 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:46:03 +0800 Subject: [PATCH 05/10] fix: bootstrap Kubernetes session secret for one-command deployment --- release/kubernetes/dubbo-system/deploy.sh | 22 +++++++++++++++++++ .../kubernetes/dubbo-system/dubbo-admin.yaml | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100755 release/kubernetes/dubbo-system/deploy.sh diff --git a/release/kubernetes/dubbo-system/deploy.sh b/release/kubernetes/dubbo-system/deploy.sh new file mode 100755 index 000000000..0bbfcd0f5 --- /dev/null +++ b/release/kubernetes/dubbo-system/deploy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +namespace=dubbo-system +secret_name=dubbo-admin-auth +manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! kubectl get namespace "$namespace" >/dev/null 2>&1; then + kubectl create namespace "$namespace" +fi + +if kubectl -n "$namespace" get secret "$secret_name" >/dev/null 2>&1; then + secret_value="$(kubectl -n "$namespace" get secret "$secret_name" -o jsonpath='{.data.session-secret}')" + if [[ -z "$secret_value" ]]; then + printf 'Secret %s/%s is missing session-secret. Fix it before deploying.\n' "$namespace" "$secret_name" >&2 + exit 1 + fi +else + openssl rand -base64 48 | kubectl -n "$namespace" create secret generic "$secret_name" --from-file=session-secret=/dev/stdin +fi + +kubectl apply -f "$manifest_dir" diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml index ddf64d8a5..72bb234e0 100644 --- a/release/kubernetes/dubbo-system/dubbo-admin.yaml +++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml @@ -12,8 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# Create the session secret before applying this manifest: -# kubectl -n dubbo-system create secret generic dubbo-admin-auth --from-literal=session-secret="$(openssl rand -base64 32)" +# Run ./deploy.sh to create a persistent session Secret and apply this directory. +# Direct kubectl apply requires the dubbo-admin-auth Secret to exist first. apiVersion: apps/v1 kind: Deployment From 96717ae1abbe253413d9168f744051b9f9515ad1 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:17:30 +0800 Subject: [PATCH 06/10] docs: document one-command Kubernetes deployment --- README.md | 2 ++ release/kubernetes/dubbo-system/README.md | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 release/kubernetes/dubbo-system/README.md diff --git a/README.md b/README.md index 04c1d1cbc..d60329b5d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ The main code repositories of Dubbo Admin include: ## Quick Start Please refer to [official website](https://cn.dubbo.apache.org/zh-cn/overview/home/). +For the Kubernetes manifests, see [Kubernetes deployment](release/kubernetes/dubbo-system/README.md). + ## Roadmap Please refer to [RoadMap](https://github.com/apache/dubbo-admin/discussions/1300). diff --git a/release/kubernetes/dubbo-system/README.md b/release/kubernetes/dubbo-system/README.md new file mode 100644 index 000000000..4f657bb48 --- /dev/null +++ b/release/kubernetes/dubbo-system/README.md @@ -0,0 +1,17 @@ +# Kubernetes deployment + +From the repository root, run: + +```sh +./release/kubernetes/dubbo-system/deploy.sh +``` + +The script creates the `dubbo-system` namespace if needed, generates a random +`session-secret` in the `dubbo-admin-auth` Kubernetes Secret on first install, +and applies the manifests in this directory. It keeps the existing Secret on +later runs, so the signing key remains stable across upgrades. + +This command requires `kubectl` access to create the namespace, Secret, and +manifest resources, plus `openssl` for the initial key generation. To apply the +manifests directly with `kubectl apply -f`, create `dubbo-admin-auth` with a +`session-secret` key in the `dubbo-system` namespace first. From fc3e56ba145feb2fef2003a4f4957d7e6d9ff2a4 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:28:25 +0800 Subject: [PATCH 07/10] fix: validate one-command Kubernetes deployment in cluster --- release/kubernetes/dubbo-system/README.md | 17 +++++++++++++++++ release/kubernetes/dubbo-system/deploy.sh | 18 +++++++++++++++++- release/kubernetes/dubbo-system/nacos.yaml | 14 ++++++-------- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/release/kubernetes/dubbo-system/README.md b/release/kubernetes/dubbo-system/README.md index 4f657bb48..53d25f55a 100644 --- a/release/kubernetes/dubbo-system/README.md +++ b/release/kubernetes/dubbo-system/README.md @@ -1,3 +1,20 @@ + + # Kubernetes deployment From the repository root, run: diff --git a/release/kubernetes/dubbo-system/deploy.sh b/release/kubernetes/dubbo-system/deploy.sh index 0bbfcd0f5..3f5e747eb 100755 --- a/release/kubernetes/dubbo-system/deploy.sh +++ b/release/kubernetes/dubbo-system/deploy.sh @@ -1,4 +1,19 @@ #!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + set -euo pipefail namespace=dubbo-system @@ -16,7 +31,8 @@ if kubectl -n "$namespace" get secret "$secret_name" >/dev/null 2>&1; then exit 1 fi else - openssl rand -base64 48 | kubectl -n "$namespace" create secret generic "$secret_name" --from-file=session-secret=/dev/stdin + generated_secret="$(openssl rand -base64 48 | tr -d '\r\n')" + printf '%s' "$generated_secret" | kubectl -n "$namespace" create secret generic "$secret_name" --from-file=session-secret=/dev/stdin fi kubectl apply -f "$manifest_dir" diff --git a/release/kubernetes/dubbo-system/nacos.yaml b/release/kubernetes/dubbo-system/nacos.yaml index 315579421..c9f65f36a 100644 --- a/release/kubernetes/dubbo-system/nacos.yaml +++ b/release/kubernetes/dubbo-system/nacos.yaml @@ -81,11 +81,9 @@ spec: selector: app: nacos ports: - - port: 8848 - name: server - targetPort: 8848 - nodePort: 30848 - - port: 9848 - name: client-rpc - targetPort: 9848 - nodePort: 31848 \ No newline at end of file + - port: 8848 + name: server + targetPort: 8848 + - port: 9848 + name: client-rpc + targetPort: 9848 From 1f3a23e020148e8825ad8dc29a5970d650b8bf28 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:24:41 +0800 Subject: [PATCH 08/10] fix: complete Kubernetes console config for secret injection --- release/kubernetes/dubbo-system/dubbo-admin.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml index 72bb234e0..ebfa0bf57 100644 --- a/release/kubernetes/dubbo-system/dubbo-admin.yaml +++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml @@ -105,6 +105,8 @@ data: grafana: http://grafana.monitoringg.svc:3000 prometheus: http://prometheus-k8s.monitoring.svc:9090/ console: + port: 8888 + ginMode: release auth: user: admin password: dubbo@2025 From 01da4fd00418a2aca02e370f05b0745aca181968 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:44:57 +0800 Subject: [PATCH 09/10] fix: reject invalid Kubernetes session secrets --- release/kubernetes/dubbo-system/README.md | 10 +++++++++- release/kubernetes/dubbo-system/deploy.sh | 14 +++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/release/kubernetes/dubbo-system/README.md b/release/kubernetes/dubbo-system/README.md index 53d25f55a..a4731d8bd 100644 --- a/release/kubernetes/dubbo-system/README.md +++ b/release/kubernetes/dubbo-system/README.md @@ -26,9 +26,17 @@ From the repository root, run: The script creates the `dubbo-system` namespace if needed, generates a random `session-secret` in the `dubbo-admin-auth` Kubernetes Secret on first install, and applies the manifests in this directory. It keeps the existing Secret on -later runs, so the signing key remains stable across upgrades. +later runs, so the signing key remains stable across upgrades. An existing +Secret without `session-secret`, or with a value shorter than 32 bytes, stops +deployment instead of silently changing the key. This command requires `kubectl` access to create the namespace, Secret, and manifest resources, plus `openssl` for the initial key generation. To apply the manifests directly with `kubectl apply -f`, create `dubbo-admin-auth` with a `session-secret` key in the `dubbo-system` namespace first. + +The manifest currently references `apache/dubbo-admin:0.7.0`, which predates +the session-secret validation in this change. When publishing the fix, update +that image tag to a release built from the fixed source. Until then, use an +image built from this branch for deployment testing; merely injecting a Secret +into the old image does not fix its cookie-signing behavior. diff --git a/release/kubernetes/dubbo-system/deploy.sh b/release/kubernetes/dubbo-system/deploy.sh index 3f5e747eb..19364b721 100755 --- a/release/kubernetes/dubbo-system/deploy.sh +++ b/release/kubernetes/dubbo-system/deploy.sh @@ -20,6 +20,13 @@ namespace=dubbo-system secret_name=dubbo-admin-auth manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +for command in kubectl openssl; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'Required command not found: %s\n' "$command" >&2 + exit 1 + fi +done + if ! kubectl get namespace "$namespace" >/dev/null 2>&1; then kubectl create namespace "$namespace" fi @@ -30,8 +37,13 @@ if kubectl -n "$namespace" get secret "$secret_name" >/dev/null 2>&1; then printf 'Secret %s/%s is missing session-secret. Fix it before deploying.\n' "$namespace" "$secret_name" >&2 exit 1 fi + secret_length="$(printf '%s' "$secret_value" | openssl base64 -d -A | wc -c | tr -d '[:space:]')" + if (( secret_length < 32 )); then + printf 'Secret %s/%s has a session-secret shorter than 32 bytes. Replace it before deploying.\n' "$namespace" "$secret_name" >&2 + exit 1 + fi else - generated_secret="$(openssl rand -base64 48 | tr -d '\r\n')" + generated_secret="$(openssl rand -hex 32)" printf '%s' "$generated_secret" | kubectl -n "$namespace" create secret generic "$secret_name" --from-file=session-secret=/dev/stdin fi From 56e875a1b63a295ac37131fd9587e6bb4b11b101 Mon Sep 17 00:00:00 2001 From: 28Hus <93303005+28Hus@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:22:34 +0800 Subject: [PATCH 10/10] fix: require patched image for Kubernetes deployment --- release/kubernetes/dubbo-system/README.md | 36 ++++++++++-------- release/kubernetes/dubbo-system/deploy.sh | 38 ++++++++++++++++++- .../kubernetes/dubbo-system/dubbo-admin.yaml | 4 +- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/release/kubernetes/dubbo-system/README.md b/release/kubernetes/dubbo-system/README.md index a4731d8bd..b22938423 100644 --- a/release/kubernetes/dubbo-system/README.md +++ b/release/kubernetes/dubbo-system/README.md @@ -17,26 +17,30 @@ limitations under the License. # Kubernetes deployment -From the repository root, run: +The bundled `apache/dubbo-admin:0.7.0` image predates the session-secret fix. +Until a fixed official image is published, build this branch and make its image +available to your cluster. From the repository root, deploy it with: ```sh -./release/kubernetes/dubbo-system/deploy.sh +DUBBO_ADMIN_IMAGE= ./release/kubernetes/dubbo-system/deploy.sh ``` -The script creates the `dubbo-system` namespace if needed, generates a random +The script requires an explicit image built from the fixed source, renders it +into the Deployment before applying anything, and rejects the known old +`0.7.0` tag and `latest`. It cannot verify the contents of a supplied image; +the operator must provide a fixed build. +It then creates the `dubbo-system` namespace if needed, generates a random `session-secret` in the `dubbo-admin-auth` Kubernetes Secret on first install, -and applies the manifests in this directory. It keeps the existing Secret on -later runs, so the signing key remains stable across upgrades. An existing -Secret without `session-secret`, or with a value shorter than 32 bytes, stops -deployment instead of silently changing the key. +and applies the manifests. It keeps the existing Secret on later runs, so the +signing key remains stable across upgrades. An existing Secret without +`session-secret`, or with a value shorter than 32 bytes, stops deployment. This command requires `kubectl` access to create the namespace, Secret, and -manifest resources, plus `openssl` for the initial key generation. To apply the -manifests directly with `kubectl apply -f`, create `dubbo-admin-auth` with a -`session-secret` key in the `dubbo-system` namespace first. - -The manifest currently references `apache/dubbo-admin:0.7.0`, which predates -the session-secret validation in this change. When publishing the fix, update -that image tag to a release built from the fixed source. Until then, use an -image built from this branch for deployment testing; merely injecting a Secret -into the old image does not fix its cookie-signing behavior. +manifest resources, plus `openssl` for the initial key generation. An image +reference pinned by `@sha256:` is also accepted. Do not apply these +manifests directly with `kubectl apply -f`: that bypasses the image check and +uses the old image. + +When publishing the fix, update the manifest and script to use a release built +from the fixed source by default. Merely injecting a Secret into the old image +does not change its cookie-signing behavior. diff --git a/release/kubernetes/dubbo-system/deploy.sh b/release/kubernetes/dubbo-system/deploy.sh index 19364b721..e634ecc0a 100755 --- a/release/kubernetes/dubbo-system/deploy.sh +++ b/release/kubernetes/dubbo-system/deploy.sh @@ -19,6 +19,30 @@ set -euo pipefail namespace=dubbo-system secret_name=dubbo-admin-auth manifest_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +image="${DUBBO_ADMIN_IMAGE:-}" + +if [[ -z "$image" ]]; then + printf 'Set DUBBO_ADMIN_IMAGE to an image built with the session-secret fix; the bundled 0.7.0 image is vulnerable.\n' >&2 + exit 1 +fi + +if [[ "$image" == *@sha256:* ]]; then + image_name="${image%@sha256:*}" + image_digest="${image##*@sha256:}" + if [[ ! "$image_name" =~ ^[A-Za-z0-9._:/-]+$ || ! "$image_digest" =~ ^[a-fA-F0-9]{64}$ ]]; then + printf 'DUBBO_ADMIN_IMAGE must be a valid image reference with a sha256 digest.\n' >&2 + exit 1 + fi + image_override=" digest: sha256:$image_digest" +else + image_name="${image%:*}" + image_tag="${image##*:}" + if [[ "$image_name" == "$image" || ! "$image_name" =~ ^[A-Za-z0-9._:/-]+$ || ! "$image_tag" =~ ^[A-Za-z0-9_.-]+$ || "$image_tag" == 0.7.0 || "$image_tag" == latest ]]; then + printf 'DUBBO_ADMIN_IMAGE must use an explicit fixed-image tag other than 0.7.0 or latest.\n' >&2 + exit 1 + fi + image_override=" newTag: $image_tag" +fi for command in kubectl openssl; do if ! command -v "$command" >/dev/null 2>&1; then @@ -27,6 +51,18 @@ for command in kubectl openssl; do fi done +render_dir="$(mktemp -d)" +trap 'rm -rf "$render_dir"' EXIT +cp "$manifest_dir"/*.yaml "$render_dir"/ +{ + printf 'apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n' + for manifest in "$manifest_dir"/*.yaml; do + printf ' - %s\n' "$(basename "$manifest")" + done + printf 'images:\n - name: apache/dubbo-admin\n newName: %s\n%s\n' "$image_name" "$image_override" +} > "$render_dir/kustomization.yaml" +rendered_manifest="$(kubectl kustomize "$render_dir")" + if ! kubectl get namespace "$namespace" >/dev/null 2>&1; then kubectl create namespace "$namespace" fi @@ -47,4 +83,4 @@ else printf '%s' "$generated_secret" | kubectl -n "$namespace" create secret generic "$secret_name" --from-file=session-secret=/dev/stdin fi -kubectl apply -f "$manifest_dir" +printf '%s\n' "$rendered_manifest" | kubectl apply -f - diff --git a/release/kubernetes/dubbo-system/dubbo-admin.yaml b/release/kubernetes/dubbo-system/dubbo-admin.yaml index 8c8c5b807..0820d8ac4 100644 --- a/release/kubernetes/dubbo-system/dubbo-admin.yaml +++ b/release/kubernetes/dubbo-system/dubbo-admin.yaml @@ -12,8 +12,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# Run ./deploy.sh to create a persistent session Secret and apply this directory. -# Direct kubectl apply requires the dubbo-admin-auth Secret to exist first. +# Run ./deploy.sh with DUBBO_ADMIN_IMAGE set to a fixed image. Do not apply +# this file directly: the pinned 0.7.0 image predates the session-secret fix. apiVersion: apps/v1 kind: Deployment