feat(admin): temporary password fallback with login hint and password masking

This commit is contained in:
wweir
2026-08-10 16:17:57 +08:00
parent 52f9205e6a
commit 033a070db6
22 changed files with 2128 additions and 1877 deletions
+1 -2
View File
@@ -117,13 +117,12 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre
## Design Decisions
- Client and server both fail fast on invalid startup configuration instead of silently degrading.
- The client loads TOML by default, with YAML kept as an alternate file format; HCL is not supported.
- The client and server load TOML configuration files only; YAML and HCL are not supported.
- Sensitive configuration values must never be printed verbatim in logs.
- Local listeners use explicit shutdown hooks instead of blocking forever with unmanaged goroutines.
- Network operations use timeouts and `context` to limit hangs during dialing and remote rule downloads.
- The admin console persists rule changes as bounded deltas in `admin.state_file`, without rewriting TOML or rule sources. It also exposes a sanitized effective-config view and persists whitelisted overrides: `log_level` and the DNS upstreams apply immediately, every other whitelisted field (remote, listeners, rule sources) takes effect on the next restart. Clearing an override reverts the field to the file/flag configuration. `--ignore-admin-state` is the startup escape hatch for a bad state file or override.
- The admin server is disabled by default, binds to loopback by default, and requires a password when enabled; the frontend never stores the password (HttpOnly session cookie only).
- The admin server is disabled by default, binds to loopback by default, and requires a password when enabled; an empty password falls back to a startup-generated random one printed once in the log, and the login page tells the user where to find it. The frontend never stores the password (HttpOnly session cookie only).
- The admin restart endpoint replaces the process image in place (`exec` on Unix, same PID) so systemd stays unaware; on platforms without in-place restart the endpoint reports an error instead of acknowledging a restart that would fail. Sessions persist to `admin.session_file` (atomic write, 0600) so a restart keeps the browser logged in; `admin.disable_session_persistence` and `admin.cookie_secure` tune that behavior. Session persistence is best-effort: a disk write failure never blocks login or leaves a revoked session valid in memory — the in-memory state stays authoritative and the failure is logged.
- Rule hit statistics (per matched rule, per category) and rule-miss statistics (per domain for connections that matched no rule) are tracked in bounded in-memory maps fed by router observers; rule mutations invalidate the hit domain cache so counts stay attributable to the current rule set.
- The admin console can share the DNS HTTP proxy listener on port 80 when `admin.addr` equals `dns.serve:80`; classification is Host-based (origin-form requests with the listener IP as Host are admin traffic), so proxying a target whose Host equals the listener IP is inherently ambiguous and routes to admin. The HTTPS and DNS listeners speak different protocols and cannot be shared.
+4 -3
View File
@@ -276,10 +276,10 @@ sudo sowerd -i
[admin]
disable = false # 默认 true
addr = "127.0.0.1:19090" # 默认仅监听回环
password = "your_admin_password" # 启用时必填
password = "your_admin_password" # 示例值;留空则启动时自动生成临时密码
```
启动后打开 `http://127.0.0.1:19090`,输入 admin 密码即可。密码只用于换取会话 cookie,不会保存在浏览器里。
启动后打开 `http://127.0.0.1:19090`,输入 admin 密码即可。未配置 `password` 时,登录页会提示使用启动日志中打印的临时密码(每次重启更换;已登录的浏览器通过会话持久化免重新登录)。密码只用于换取会话 cookie,不会保存在浏览器里。
### Admin 配置项
@@ -287,7 +287,7 @@ password = "your_admin_password" # 启用时必填
|---|---|---|
| `disable` | `true` | 默认关闭管理台 |
| `addr` | `127.0.0.1:19090` | 监听地址;设为 `dns.serve:80` 时与 HTTP 代理共享端口 |
| `password` | 空 | 启用时必填;只用于换取会话 cookie |
| `password` | 空 | 未配置时启动自动生成一次性随机密码(打印到启动日志);只用于换取会话 cookie |
| `state_file` | `/etc/sower/admin-state.json` | 规则增量与配置覆盖的持久化文件;空字符串禁用持久化 |
| `session_file` | `/etc/sower/sessions.json` | 会话持久化文件,重启后浏览器免重新登录;仅限单进程使用 |
| `disable_session_persistence` | `false` | 设为 `true` 关闭会话持久化(重启后需重新登录) |
@@ -325,6 +325,7 @@ password = "your_admin_password"
`sower.toml` 是默认示例配置。完整示例见 [config/sower.toml](./config/sower.toml)。
配置格式仅支持 TOML(`sower.toml` / `sowerd.toml`)。旧版本支持的同名 YAML 配置文件已不再被读取,升级前请将 YAML 配置迁移为 TOML。
几个容易踩坑的点:
- 远程规则文件会通过上游代理下载,不会直接出网。
+40 -18
View File
@@ -20,6 +20,24 @@ import (
const adminShutdownTimeout = 5 * time.Second
// resolveAdminPassword returns the configured admin password, or a random one
// generated at startup when none is configured, plus whether the fallback was
// used. The generated password is printed once in the startup log
// (intentionally: it is the only way to log in) and changes on every restart;
// persisted sessions keep an already logged-in browser working across
// restarts. The temporary flag drives the /api/login-info hint on the login
// page.
func resolveAdminPassword(configured string) (password string, temporary bool) {
if configured != "" {
return configured, false
}
pw := admin.GeneratePassword()
slog.Warn("admin enabled without a password; generated a temporary password",
"password", pw,
"hint", "set [admin].password to use a fixed password")
return pw, true
}
// adminRules adapts the router's rule sets to the admin RuleManager
// interface. Mutations persist through the StateStore first; the runtime
// rule set only changes after a successful write, so a persist failure
@@ -410,16 +428,18 @@ func startAdminListener(ctx context.Context, wg *sync.WaitGroup, cfg config.Sowe
return nil
}
password, temporary := resolveAdminPassword(cfg.Admin.Password.Value())
srv := admin.NewServer(admin.Options{
Password: cfg.Admin.Password,
Version: version,
Date: date,
Rules: rules,
Stats: stats,
SessionFile: cfg.AdminSessionFile(),
CookieSecure: cfg.Admin.CookieSecure,
Config: configMgr,
Restart: restartFn(restartCh),
Password: password,
TemporaryPassword: temporary,
Version: version,
Date: date,
Rules: rules,
Stats: stats,
SessionFile: cfg.AdminSessionFile(),
CookieSecure: cfg.Admin.CookieSecure,
Config: configMgr,
Restart: restartFn(restartCh),
})
ln, err := net.Listen("tcp", cfg.Admin.Addr)
@@ -458,16 +478,18 @@ func startSharedHTTPListener(ctx context.Context, wg *sync.WaitGroup, cfg config
}
slog.Info("service listening", "service", "http proxy + admin", "network", "tcp", "addr", addr)
password, temporary := resolveAdminPassword(cfg.Admin.Password.Value())
srv := admin.NewServer(admin.Options{
Password: cfg.Admin.Password,
Version: version,
Date: date,
Rules: rules,
Stats: stats,
SessionFile: cfg.AdminSessionFile(),
CookieSecure: cfg.Admin.CookieSecure,
Config: configMgr,
Restart: restartFn(restartCh),
Password: password,
TemporaryPassword: temporary,
Version: version,
Date: date,
Rules: rules,
Stats: stats,
SessionFile: cfg.AdminSessionFile(),
CookieSecure: cfg.Admin.CookieSecure,
Config: configMgr,
Restart: restartFn(restartCh),
})
go func() {
<-ctx.Done()
+18 -2
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
"github.com/sower-proxy/sower/router"
@@ -253,13 +254,13 @@ func TestAdminConfigViewAndApply(t *testing.T) {
base := config.SowerConfig{LogLevel: slog.LevelInfo}
base.Remote.Type = "sower"
base.Remote.Addr = "proxy.example.com"
base.Remote.Password = "supersecret"
base.Remote.Password = deferlog.NewPassword("supersecret")
base.DNS.Serve = "127.0.0.1"
base.DNS.Upstream = "8.8.8.8"
base.DNS.Fallback = "223.5.5.5"
base.Socks5.Addr = "127.0.0.1:1080"
base.Admin.Addr = "127.0.0.1:19090"
base.Admin.Password = "adminsecret"
base.Admin.Password = deferlog.NewPassword("adminsecret")
base.Admin.StateFile = "/etc/sower/admin-state.json"
state := admin.LoadStateStore("")
@@ -467,6 +468,21 @@ func runAdminRulesMutation(i int, rules *adminRules, errs chan<- error, wg *sync
}
}
func TestResolveAdminPassword(t *testing.T) {
t.Parallel()
if pw, temp := resolveAdminPassword("secret"); pw != "secret" || temp {
t.Fatalf("configured password: got (%q, %v), want (secret, false)", pw, temp)
}
pw, temp := resolveAdminPassword("")
if pw == "" || !temp {
t.Fatalf("empty password: got (%q, %v), want (non-empty, true)", pw, temp)
}
if pw2, _ := resolveAdminPassword(""); pw2 == pw {
t.Fatal("temporary password must differ across startups")
}
}
func TestRestartFn(t *testing.T) {
ch := make(chan struct{}, 1)
fn := restartFn(ch)
+2 -2
View File
@@ -153,7 +153,7 @@ func (ac *adminConfig) configViewLocked() admin.ConfigView {
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteAddr != nil),
Constraint: "代理地址,如 proxy.com 或 proxy.com:443"},
{Key: "remote.password", ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig,
Secret: true, Configured: cfg.Remote.Password != ""},
Secret: true, Configured: cfg.Remote.Password.Value() != ""},
{Key: "remote.tls.server_name", Value: cfg.Remote.TLS.ServerName, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.RemoteTLSServerName != nil),
Constraint: "覆盖上游 TLS SNI;留空使用地址"},
@@ -184,7 +184,7 @@ func (ac *adminConfig) configViewLocked() admin.ConfigView {
Constraint: "SOCKS5 监听地址,如 127.0.0.1:1080"},
{Key: "admin.addr", Value: cfg.Admin.Addr, ApplyMode: admin.ApplyRestart, Source: admin.SourceConfig},
{Key: "admin.password", ApplyMode: admin.ApplyReadonly, Source: admin.SourceConfig,
Secret: true, Configured: cfg.Admin.Password != ""},
Secret: true, Configured: cfg.Admin.Password.Value() != ""},
{Key: "admin.session_file", Value: cfg.Admin.SessionFile, Editable: true,
ApplyMode: admin.ApplyRestart, Source: source(overrides.AdminSessionFile != nil),
Constraint: "会话持久化文件路径;留空不持久化"},
+2 -2
View File
@@ -113,7 +113,7 @@ func init() {
"log_level", conf.LogLevel,
"remote_type", conf.Remote.Type,
"remote_addr", conf.Remote.Addr,
"remote_password", deferlog.Secret(conf.Remote.Password),
"remote_password", conf.Remote.Password,
"remote_tls", conf.Remote.TLS,
"dns", conf.DNS,
"socks5", conf.Socks5,
@@ -136,7 +136,7 @@ func run(ctx context.Context, stop context.CancelFunc, cfg config.SowerConfig) e
applyConfigOverrides(&cfg, stateStore.ConfigOverrides())
upstreamDNS := effectiveUpstreamDNS(cfg)
proxyDial, err := GenProxyDial(cfg.Remote.Type, cfg.Remote.Addr, cfg.Remote.Password, upstreamDNS, upstreamtls.Options{
proxyDial, err := GenProxyDial(cfg.Remote.Type, cfg.Remote.Addr, cfg.Remote.Password.Value(), upstreamDNS, upstreamtls.Options{
ServerName: cfg.Remote.TLS.ServerName,
ClientHello: cfg.Remote.TLS.ClientHello,
InsecureSkipVerify: cfg.Remote.TLS.InsecureSkipVerify,
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/internal/admin"
)
@@ -22,7 +23,7 @@ func TestSharedAdminHTTPAddr(t *testing.T) {
base := config.SowerConfig{}
base.DNS.Serve = "127.0.0.1"
base.Admin.Addr = "127.0.0.1:80"
base.Admin.Password = "secret"
base.Admin.Password = deferlog.NewPassword("secret")
cfg := base
if addr, ok := sharedAdminHTTPAddr(cfg); !ok || addr != "127.0.0.1:80" {
+1 -1
View File
@@ -144,7 +144,7 @@ func run(ctx context.Context, conf config.SowerdConfig) error {
defer ln.Close()
protocolHandlers := []proxyProtocolHandler{
newSowerProtocolHandler(transportSower.New(conf.Password)),
newSowerProtocolHandler(transportSower.New(conf.Password.Value())),
}
httpsErrCh := make(chan error, 1)
+12 -14
View File
@@ -6,6 +6,7 @@ import (
"net"
"strings"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
@@ -16,10 +17,10 @@ type RemoteTLSConfig struct {
}
type RemoteConfig struct {
Type string `default:"sower" required:"true" usage:"option: sower/socks5"`
Addr string `required:"true" usage:"proxy address, eg: proxy.com or proxy.com:443"`
Password string `usage:"remote proxy password"`
TLS RemoteTLSConfig `flag:"tls"`
Type string `default:"sower" required:"true" usage:"option: sower/socks5"`
Addr string `required:"true" usage:"proxy address, eg: proxy.com or proxy.com:443"`
Password deferlog.Password `usage:"remote proxy password"`
TLS RemoteTLSConfig `flag:"tls"`
}
// SowerConfig represents the configuration for sower client
@@ -46,13 +47,13 @@ type SowerConfig struct {
} `flag:"socks5"`
Admin struct {
Disable bool `default:"true" usage:"disable admin web server"`
Addr string `default:"127.0.0.1:19090" usage:"admin web server listen address"`
Password string `usage:"admin web server password, required when enabled"`
SessionFile string `default:"/etc/sower/sessions.json" usage:"persist admin sessions to this file; one sower process only"`
DisableSessionPersistence bool `default:"false" usage:"disable admin session persistence"`
CookieSecure bool `default:"false" usage:"set Secure on admin session cookies behind a TLS-terminating proxy"`
StateFile string `default:"/etc/sower/admin-state.json" usage:"persist admin rule and config changes to this file, empty disables persistence"`
Disable bool `default:"true" usage:"disable admin web server"`
Addr string `default:"127.0.0.1:19090" usage:"admin web server listen address"`
Password deferlog.Password `usage:"admin web server password; empty generates a random one at startup"`
SessionFile string `default:"/etc/sower/sessions.json" usage:"persist admin sessions to this file; one sower process only"`
DisableSessionPersistence bool `default:"false" usage:"disable admin session persistence"`
CookieSecure bool `default:"false" usage:"set Secure on admin session cookies behind a TLS-terminating proxy"`
StateFile string `default:"/etc/sower/admin-state.json" usage:"persist admin rule and config changes to this file, empty disables persistence"`
} `flag:"admin"`
Router struct {
@@ -165,9 +166,6 @@ func (c *SowerConfig) Validate() error {
if err != nil || host == "" {
return fmt.Errorf("invalid admin listen address %q", c.Admin.Addr)
}
if c.Admin.Password == "" {
return fmt.Errorf("admin password is required when admin is enabled")
}
// The admin console can share the DNS HTTP proxy listener on port 80
// (admin.addr == dns.serve:80), but it cannot share the HTTPS or DNS
// listeners, which speak different protocols.
+4 -3
View File
@@ -19,7 +19,7 @@ insecure_skip_verify = false # Skip upstream TLS certificate verification
[dns]
disable = false # Disable DNS proxy
serve = "127.0.0.1" # DNS server IP address
serve_6 = "" # DNS server IPv6 address (optional); aconfig maps Serve6 -> serve_6
serve_6 = "" # DNS server IPv6 address (optional)
serve_iface = "" # Network interface to get IP from (optional)
upstream = "8.8.8.8" # Upstream DNS server (optional)
fallback = "223.5.5.5" # Fallback DNS server
@@ -32,7 +32,8 @@ addr = "127.0.0.1:1080" # SOCKS5 listen address
# Admin web server configuration
# Serves the embedded admin console for runtime rule management and traffic monitoring.
# Disabled by default; when enabled, a non-empty password is required.
# Disabled by default; when enabled, set a password or one is generated at startup
# and printed to the log.
#
# Standalone mode (default): admin listens on its own address, e.g. 127.0.0.1:19090.
# Shared mode: set addr to the DNS HTTP proxy address (dns.serve:80) to serve the
@@ -42,7 +43,7 @@ addr = "127.0.0.1:1080" # SOCKS5 listen address
[admin]
disable = true # Disable admin web server
addr = "127.0.0.1:19090" # Admin web server listen address
password = "" # Admin web server password (required when enabled)
password = "your_admin_password" # Admin web server password; empty = random at startup
session_file = "/etc/sower/sessions.json" # Persist admin sessions for one sower process only
disable_session_persistence = false # Set true to disable session persistence
cookie_secure = false # Set true only when a TLS-terminating proxy protects this origin
+112 -13
View File
@@ -1,11 +1,15 @@
package config
import (
"bytes"
"log/slog"
"os"
"strings"
"testing"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
"github.com/sower-proxy/deferlog/v2"
)
func TestSowerConfigValidateRejectsInvalidRemoteType(t *testing.T) {
@@ -84,7 +88,11 @@ func TestSowerConfigValidateRejectsInvalidTLSClientHello(t *testing.T) {
}
}
func TestSowerConfigValidateAdminRequiresPassword(t *testing.T) {
// TestSowerConfigValidateAdminAllowsEmptyPassword pins the startup-fallback
// contract: enabling the admin console without a password must not fail
// validation; cmd/sower generates a random password at runtime and prints it
// to the startup log.
func TestSowerConfigValidateAdminAllowsEmptyPassword(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
@@ -96,8 +104,8 @@ func TestSowerConfigValidateAdminRequiresPassword(t *testing.T) {
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1:19090"
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for missing admin password")
if err := cfg.Validate(); err != nil {
t.Fatalf("validate admin with empty password: %v", err)
}
}
@@ -112,7 +120,7 @@ func TestSowerConfigValidateAdminRejectsInvalidAddr(t *testing.T) {
cfg.Socks5.Disable = true
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1"
cfg.Admin.Password = "secret"
cfg.Admin.Password = deferlog.NewPassword("secret")
if err := cfg.Validate(); err == nil {
t.Fatal("expected validation error for invalid admin address")
@@ -130,25 +138,52 @@ func TestSowerConfigValidateAdminAllowsValidConfig(t *testing.T) {
cfg.Socks5.Disable = true
cfg.Admin.Disable = false
cfg.Admin.Addr = "127.0.0.1:19090"
cfg.Admin.Password = "secret"
cfg.Admin.Password = deferlog.NewPassword("secret")
if err := cfg.Validate(); err != nil {
t.Fatalf("validate config: %v", err)
}
}
func TestSowerConfigValidateAdminZeroValueIsIgnored(t *testing.T) {
// TestSowerConfigAdminDisabledByDefault pins the upgrade-compatible default:
// a config without an [admin] section must load with admin disabled (the aconfig
// default) and pass validation, so existing deployments keep working after
// upgrading without adding an [admin] block or password.
func TestSowerConfigAdminDisabledByDefault(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Type = "sower"
cfg.Remote.Addr = "example.com"
cfg.DNS.Disable = true
cfg.DNS.Fallback = "223.5.5.5"
cfg.Socks5.Disable = true
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
[dns]
serve = "127.0.0.1"
fallback = "223.5.5.5"
[socks_5]
disable = true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if !cfg.Admin.Disable {
t.Fatal("expected admin disabled by default without an [admin] section")
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate zero-value admin: %v", err)
t.Fatalf("validate config without admin section: %v", err)
}
}
@@ -235,3 +270,67 @@ func TestSowerConfigLoadsPackagedExamples(t *testing.T) {
})
}
}
// TestSowerConfigPasswordFieldsRedactOnLog pins the masking contract: logging
// or printing a SowerConfig must never leak configured passwords. The
// password fields use deferlog.Password, which renders as *** through
// fmt.Stringer while keeping the plain value reachable via Value().
func TestSowerConfigPasswordFieldsRedactOnLog(t *testing.T) {
t.Parallel()
cfg := SowerConfig{}
cfg.Remote.Password = deferlog.NewPassword("topsecret-remote")
cfg.Admin.Password = deferlog.NewPassword("topsecret-admin")
var buf bytes.Buffer
slog.New(slog.NewTextHandler(&buf, nil)).Error("load config", "config", cfg)
out := buf.String()
for _, secret := range []string{"topsecret-remote", "topsecret-admin"} {
if strings.Contains(out, secret) {
t.Fatalf("secret %q leaked in slog output", secret)
}
}
if !strings.Contains(out, "***") {
t.Fatal("expected masked *** in slog output")
}
}
// TestSowerConfigPasswordAcceptsBase64 pins that password fields decode
// canonical base64 values from TOML (a deferlog.Password feature), so
// operators can avoid writing plaintext secrets into config files.
func TestSowerConfigPasswordAcceptsBase64(t *testing.T) {
t.Parallel()
path := t.TempDir() + "/sower.toml"
if err := os.WriteFile(path, []byte(`
[remote]
type = "sower"
addr = "example.com"
password = "c2VjcmV0LXBhc3M="
[dns]
disable = true
fallback = "223.5.5.5"
[socks_5]
disable = true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var cfg SowerConfig
if err := aconfig.LoaderFor(&cfg, aconfig.Config{
SkipEnv: true,
SkipFlags: true,
Files: []string{path},
FileDecoders: map[string]aconfig.FileDecoder{
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
t.Fatalf("load config: %v", err)
}
if got := cfg.Remote.Password.Value(); got != "secret-pass" {
t.Fatalf("base64 password = %q, want secret-pass", got)
}
}
+8 -6
View File
@@ -9,6 +9,8 @@ import (
"net/url"
"os"
"strings"
"github.com/sower-proxy/deferlog/v2"
)
const ExampleSowerdConfigTOML = `# Sowerd configuration example (TOML format)
@@ -52,11 +54,11 @@ type SiteRoute struct {
// SowerdConfig represents the configuration for sowerd daemon
type SowerdConfig struct {
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
ServeIP string `usage:"listen to port 80 443 of the IP"`
Password string `required:"true"`
FakeSite string `default:"/var/www" usage:"fake site address or directoy. serving on 127.0.0.1:80 if directory"`
SiteRoutes []SiteRoute `usage:"domain-to-upstream routing rules for fallback traffic"`
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
ServeIP string `usage:"listen to port 80 443 of the IP"`
Password deferlog.Password `required:"true"`
FakeSite string `default:"/var/www" usage:"fake site address or directoy. serving on 127.0.0.1:80 if directory"`
SiteRoutes []SiteRoute `usage:"domain-to-upstream routing rules for fallback traffic"`
Cert struct {
Email string
@@ -70,7 +72,7 @@ func (c *SowerdConfig) Validate() error {
if c.ServeIP != "" && net.ParseIP(c.ServeIP) == nil {
return fmt.Errorf("invalid serve ip: %q", c.ServeIP)
}
if c.Password == "" {
if c.Password.Value() == "" {
return fmt.Errorf("password is required")
}
if c.FakeSite == "" {
+3 -4
View File
@@ -26,10 +26,10 @@ Scope: `cmd/sower`(客户端)管理控制台,前端 embed 进二进制
[admin]
disable = true # 默认禁用
addr = "127.0.0.1:19090" # 默认仅回环
password = "" # 启用时必填
password = "" # 为空时启动自动生成
```
校验规则:`disable=false` 时,`addr` 必须是合法 `host:port`,且 `password` 非空;否则启动失败。
校验规则:`disable=false` 时,`addr` 必须是合法 `host:port`;`password` 为空时启动自动生成一次性随机密码并打印到日志(已实现变更,见 `ARCHITECTURE.md`)。
## 后端设计
@@ -128,5 +128,4 @@ API:
## 文档收尾
- 定稿后回写 `ARCHITECTURE.md`(admin 边界、数据流、设计决策)与 `README.md`(admin 用法)
- `config/sower.toml` / `sower.yaml` 示例同步 `[admin]` 段
- 定稿后回写 `ARCHITECTURE.md`(admin 边界、数据流、设计决策)与 `README.md`(admin 用法);`[admin]` 段示例以 `config/sower.toml` 为准
+13
View File
@@ -245,3 +245,16 @@ func secureEqual(a, b string) bool {
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// GeneratePassword returns a cryptographically random 128-bit password
// (hex-encoded). cmd/sower uses it when the admin console is enabled
// without a configured password, so the listener never runs with an empty
// credential. The generated value is printed once in the startup log and
// changes on every restart.
func GeneratePassword() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(fmt.Sprintf("read crypto/rand: %v", err))
}
return hex.EncodeToString(b[:])
}
+21
View File
@@ -0,0 +1,21 @@
package admin
import (
"encoding/hex"
"testing"
)
func TestGeneratePassword(t *testing.T) {
t.Parallel()
pw := GeneratePassword()
if len(pw) != 32 {
t.Fatalf("GeneratePassword() length = %d, want 32 (16 random bytes hex-encoded)", len(pw))
}
if _, err := hex.DecodeString(pw); err != nil {
t.Fatalf("GeneratePassword() is not hex-encoded: %v", err)
}
if pw == GeneratePassword() {
t.Fatal("two GeneratePassword() calls returned the same value")
}
}
+19 -7
View File
@@ -80,13 +80,14 @@ type RuleManager interface {
// Options configures the admin server.
type Options struct {
Password string
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
CookieSecure bool
Password string
TemporaryPassword bool
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
CookieSecure bool
// Config enables the config display/edit endpoints when non-nil.
Config ConfigManager
// Restart triggers a process restart when non-nil; the endpoint returns
@@ -123,6 +124,7 @@ func NewServer(opts Options) *Server {
mux.HandleFunc("POST /api/session", s.handleLogin)
mux.HandleFunc("GET /api/session", s.mutateGuard(s.auth(s.handleSession)))
mux.HandleFunc("DELETE /api/session", s.handleLogout)
mux.HandleFunc("GET /api/login-info", s.handleLoginInfo)
mux.HandleFunc("GET /api/status", s.mutateGuard(s.auth(s.handleStatus)))
mux.HandleFunc("GET /api/rules", s.mutateGuard(s.auth(s.handleRulesList)))
mux.HandleFunc("POST /api/rules", s.mutateGuard(s.auth(s.handleRulesAdd)))
@@ -168,6 +170,16 @@ func (s *Server) Shutdown(ctx context.Context) error {
return s.http.Shutdown(ctx)
}
// handleLoginInfo reports whether the admin console runs with a
// startup-generated temporary password, so the login page can tell the user
// where to find it. It is intentionally public: the login page needs it
// before authenticating, and it leaks no credential material.
func (s *Server) handleLoginInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"temporaryPassword": s.opts.TemporaryPassword,
})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.statusPayload())
}
+45
View File
@@ -326,6 +326,51 @@ func decodeBody(t *testing.T, resp *http.Response) map[string]any {
return out
}
func TestLoginInfoTemporaryPassword(t *testing.T) {
s := NewServer(Options{Password: "generated", TemporaryPassword: true, Rules: newFakeRules()})
ts := httptest.NewServer(s.http.Handler)
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/api/login-info")
if err != nil {
t.Fatalf("get login info: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var out struct {
TemporaryPassword bool `json:"temporaryPassword"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode body: %v", err)
}
if !out.TemporaryPassword {
t.Fatal("expected temporaryPassword=true for a startup-generated password")
}
}
func TestLoginInfoFixedPassword(t *testing.T) {
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
ts := httptest.NewServer(s.http.Handler)
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/api/login-info")
if err != nil {
t.Fatalf("get login info: %v", err)
}
defer resp.Body.Close()
var out struct {
TemporaryPassword bool `json:"temporaryPassword"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode body: %v", err)
}
if out.TemporaryPassword {
t.Fatal("expected temporaryPassword=false for a configured password")
}
}
func TestLoginWrongPassword(t *testing.T) {
ts := newTestServer(t, newFakeRules())
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"wrong"}`))
+1798
View File
File diff suppressed because one or more lines are too long
-1798
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Sower Admin</title>
<script type="module" crossorigin src="/assets/index-D9XDje5A.js"></script>
<script type="module" crossorigin src="/assets/index-C9pF9Fk-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BQCNGJsO.css">
</head>
<body>
+5
View File
@@ -195,6 +195,10 @@ export interface ConfigChanges {
router_country_rules?: string[];
}
export interface LoginInfo {
temporaryPassword: boolean;
}
export class ApiError extends Error {
constructor(
public status: number,
@@ -227,6 +231,7 @@ export const probeSession = () =>
request<void>("/api/session", { cache: "no-store" });
export const api = {
loginInfo: () => request<LoginInfo>("/api/login-info"),
login: (password: string) =>
request<void>("/api/session", {
method: "POST",
+17
View File
@@ -14,10 +14,19 @@
let password = $state('')
let error = $state('')
let busy = $state(false)
let tempPassword = $state(false)
let passwordInput: HTMLInputElement | null = $state(null)
onMount(() => {
passwordInput?.focus()
api
.loginInfo()
.then((info) => {
tempPassword = info.temporaryPassword
})
.catch(() => {
// login info is best-effort; the login form still works without it
})
})
async function submit() {
@@ -46,6 +55,14 @@
<Card.CardDescription>输入 sower 配置中的 admin 密码。</Card.CardDescription>
</Card.CardHeader>
<Card.CardContent>
{#if tempPassword}
<Alert.Alert class="mb-4">
<CircleAlert class="size-4" />
<Alert.AlertDescription>
未配置固定密码,请使用 sower 启动日志中打印的临时密码登录(每次重启会更换;已登录的浏览器不受影响)。
</Alert.AlertDescription>
</Alert.Alert>
{/if}
<form class="grid gap-4" onsubmit={(e) => { e.preventDefault(); submit() }}>
<div class="grid gap-2">
<Label for="admin-password">密码</Label>