fix(install): generate the client password instead of the placeholder

`sowerd -i` wrote config.ExampleSowerdConfigTOML verbatim, so a fresh
install started with `password = "change_me"` — a publicly known credential
— while Validate rejects only an empty password. The admin console already
generates a random password when none is configured; do the same here.

- renderDefaultConfig replaces the template's top-level `password = ` line
  with a random 128-bit hex value (the commented `# password = ...` sample
  inside site_routes.auth is left alone) and errors when the template has no
  such line, instead of silently keeping a known value.
- The password is printed once during install for the client configs, and an
  existing /etc/sower/sowerd.toml is still never rewritten.
- docs: security.md states this under "What the Transport Password Gates"
  and moves the risk out of Residual Accepted Signals — a fixable
  operational defect is not an inherent signal — recording the declined
  alternative (a placeholder blocklist in Validate) with its rationale;
  ARCHITECTURE/README describe the generated password.

Gate: gofmt, go vet ./..., go test ./... green; install_e2e_test PASS.
This commit is contained in:
wweir
2026-10-12 10:44:47 +08:00
parent 2894a15510
commit 0b27a34f3e
5 changed files with 126 additions and 11 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+15 -7
View File
@@ -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.
+47 -1
View File
@@ -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
}
+62 -1
View File
@@ -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")
}
}