mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
feat(admin): temporary password fallback with login hint and password masking
This commit is contained in:
+1
-2
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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` 为准
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"}`))
|
||||
|
||||
Vendored
+1798
File diff suppressed because one or more lines are too long
Vendored
-1798
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user