diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64cfa20..5696aef 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -114,7 +114,7 @@ For non-transport fallback traffic, it can route by TLS SNI to per-domain upstre 2. Require root privileges because the installer writes to `/usr/local/bin`, `/etc/systemd/system`, and `/etc/sower`. 3. Optionally copy or update the current binary to `/usr/local/bin/sowerd`. 4. Write `/etc/systemd/system/sowerd.service` with `ExecStart=/usr/local/bin/sowerd -c /etc/sower/sowerd.toml`. -5. Ensure `/etc/sower/sowerd.toml` exists and bootstrap `/var/www` for directory-backed fake site mode. +5. Ensure `/etc/sower/sowerd.toml` exists — a fresh one is written with a generated random client password, printed once — and bootstrap `/var/www` for directory-backed fake site mode. An existing config is never rewritten. 6. Reload systemd and optionally enable/start or restart the service. ## Design Decisions diff --git a/README.md b/README.md index 8b4f731..23c040d 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ sudo sowerd -password XXX -fake_site 127.0.0.1:8080 sudo sowerd -i ``` -安装器可以复制当前二进制到 `/usr/local/bin/sowerd`,写入 `/etc/systemd/system/sowerd.service`,在缺少配置时创建 `/etc/sower/sowerd.toml`,并按提示决定是否立即启动服务。 +安装器可以复制当前二进制到 `/usr/local/bin/sowerd`,写入 `/etc/systemd/system/sowerd.service`,在缺少配置时创建 `/etc/sower/sowerd.toml`,并按提示决定是否立即启动服务。新建配置会写入一个随机生成的客户端口令并在安装过程中打印一次(不会沿用模板里的 `change_me` 占位值);已有配置不会被覆盖,需要自己把 `password` 改成强随机值——它是传输路径上唯一的凭据。 ## 管理控制台(Admin) diff --git a/docs/security.md b/docs/security.md index c36916c..5807eb2 100644 --- a/docs/security.md +++ b/docs/security.md @@ -61,9 +61,13 @@ authorization: else rate-limits the transport path: a weak password is guessable online at roughly one TLS handshake per attempt. Only the `site_routes` visitor gate is throttled. -- **Password handling is the operator's.** Validation rejects only an empty - password (`SowerdConfig.Validate`), and the HMAC key is the raw password with - no KDF (see Residual Accepted Signals). +- **A fresh install generates its own password.** `sowerd -i` writes a random + 128-bit client password into a new `/etc/sower/sowerd.toml` and prints it once + (`internal/install`), instead of shipping the template's placeholder. The + example template keeps `change_me` for manual/development configs, and + validation rejects only an empty password (`SowerdConfig.Validate`), so a + hand-written config is the operator's responsibility. The HMAC key is the raw + password with no KDF (see Residual Accepted Signals). Use it as a transport, not as a boundary: keep the exposed service's own authentication, keep the password long and unique to this deployment, and keep @@ -225,6 +229,14 @@ Decisions recorded here so they are not re-litigated without new evidence: unknown SNI (probes and failed transport auth included) and must keep answering like an ordinary public website. An operator's real site that needs a gate belongs in `site_routes`. +- **Rejecting known-weak passwords in `SowerdConfig.Validate`** — declined: a + blocklist of placeholder strings is not a strength policy (it catches + `change_me` and nothing else), and it would stop existing deployments from + starting on upgrade. The fixable half of that risk is handled where the + config is created — the installer now generates a random password instead of + writing the template's placeholder — and the remaining operator duty (replace + the placeholder in a hand-written config) is stated under "What the Transport + Password Gates". ## Residual Accepted Signals @@ -235,7 +247,3 @@ Decisions recorded here so they are not re-litigated without new evidence: is deferred. - The `Head` HMAC key is the raw password without a KDF: only relevant to an observer who can already capture a `Head`, which requires defeating TLS. -- The shipped default config carries `password = "change_me"` (the installer - writes `config.ExampleSowerdConfigTOML`) and validation rejects only an empty - password: a deployment that never replaced the placeholder runs with a - publicly known credential. diff --git a/internal/install/service.go b/internal/install/service.go index 90dbf93..36b830e 100644 --- a/internal/install/service.go +++ b/internal/install/service.go @@ -1,6 +1,8 @@ package install import ( + "crypto/rand" + "encoding/hex" "fmt" "os" "os/exec" @@ -216,9 +218,53 @@ func ensureDefaultFiles(configPath, fakeSiteDir, defaultConfigTOML string) error return fmt.Errorf("stat default config path: %w", err) } - if err := os.WriteFile(configPath, []byte(defaultConfigTOML), 0o600); err != nil { + password, err := generatePassword() + if err != nil { + return err + } + rendered, err := renderDefaultConfig(defaultConfigTOML, password) + if err != nil { + return err + } + if err := os.WriteFile(configPath, []byte(rendered), 0o600); err != nil { return fmt.Errorf("write default config: %w", err) } fmt.Printf(" OK default config written to %s\n", configPath) + // Printed once, like the admin console's generated password: the operator + // needs it for each client's remote.password, and the file is 0600. + fmt.Printf(" Client password (set remote.password on every client): %s\n", password) return nil } + +// generatePassword returns a random 128-bit client password, hex encoded. The +// installer writes it into a fresh config instead of shipping the template's +// placeholder, which is a publicly known credential. +func generatePassword() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generate client password: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +// renderDefaultConfig replaces the template's top-level password line with the +// generated password. The template also carries a commented `password = ...` +// line inside the site_routes.auth sample, so only a line starting with +// `password = ` is replaced. A template without one is an error rather than a +// config that silently keeps a known credential. +func renderDefaultConfig(template, password string) (string, error) { + lines := strings.Split(template, "\n") + replaced := false + for i, line := range lines { + if !strings.HasPrefix(line, "password = ") { + continue + } + lines[i] = fmt.Sprintf("password = %q # Client auth password (generated by the installer)", password) + replaced = true + break + } + if !replaced { + return "", fmt.Errorf("default config template has no top-level password line") + } + return strings.Join(lines, "\n"), nil +} diff --git a/internal/install/service_test.go b/internal/install/service_test.go index 0407893..e1b6741 100644 --- a/internal/install/service_test.go +++ b/internal/install/service_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strings" "testing" ) @@ -84,9 +85,17 @@ func TestInstallServiceFreshInstall(t *testing.T) { if err != nil { t.Fatalf("read config file: %v", err) } - if string(configContent) == "" { + configText := string(configContent) + if configText == "" { t.Fatal("config file is empty") } + // A fresh install must not ship the template's publicly known placeholder. + if strings.Contains(configText, "change_me") { + t.Fatalf("fresh config still carries the placeholder password:\n%s", configText) + } + if !regexp.MustCompile(`(?m)^password = "[0-9a-f]{32}"`).MatchString(configText) { + t.Fatalf("fresh config does not carry a generated 32-hex password:\n%s", configText) + } if info, err := os.Stat(fakeSiteDir); err != nil || !info.IsDir() { t.Fatalf("fake site dir stat err = %v, isDir = %v", err, err == nil && info.IsDir()) @@ -175,3 +184,55 @@ func TestInstallServiceUpdateWithoutRestart(t *testing.T) { t.Fatalf("systemctl calls = %#v, want %#v", systemctlCalls, wantSystemctlCalls) } } + +func TestRenderDefaultConfigReplacesOnlyTheTopLevelPassword(t *testing.T) { + t.Parallel() + + // The example template also carries a commented password line inside the + // site_routes.auth sample: replacing that one instead would leave the real + // credential as the placeholder while looking correct. + template := `password = "change_me" # Client auth password (required; replace the placeholder) +fake_site = "/var/www" + +# [site_routes.auth] +# password = "change_me" # an empty block leaves the route open. +` + + got, err := renderDefaultConfig(template, "abc123") + if err != nil { + t.Fatalf("renderDefaultConfig() error = %v", err) + } + if !strings.Contains(got, `password = "abc123"`) { + t.Fatalf("rendered config does not carry the generated password:\n%s", got) + } + if strings.Count(got, "change_me") != 1 || !strings.Contains(got, `# password = "change_me"`) { + t.Fatalf("rendered config must keep the placeholder only in the commented sample:\n%s", got) + } +} + +func TestRenderDefaultConfigWithoutPasswordLine(t *testing.T) { + t.Parallel() + + if _, err := renderDefaultConfig("fake_site = \"/var/www\"\n", "abc123"); err == nil { + t.Fatal("a template without a top-level password line must be an error") + } +} + +func TestGeneratePassword(t *testing.T) { + t.Parallel() + + first, err := generatePassword() + if err != nil { + t.Fatalf("generatePassword() error = %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first) { + t.Fatalf("password = %q, want 32 hex characters", first) + } + second, err := generatePassword() + if err != nil { + t.Fatalf("generatePassword() error = %v", err) + } + if first == second { + t.Fatal("generatePassword() returned the same value twice") + } +}