mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user