Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
All checks were successful
check / check (push) Successful in 3m25s

The admin bootstrap password was printed once, as one line among roughly
45 fx lines, and under docker run -d went to container logs subject to
rotation. There was no reset path at all -- no subcommand, no forgot-password
flow, no env override -- so recovery meant hand-deleting the users row from
webhooker.db, which was documented nowhere.

Adds webhooker resetpw [-generate] <username>. The password is read from
stdin or generated with the existing crypto/rand helper, never taken from
argv where /proc would publish it. It reuses the existing Argon2id hashing
rather than reimplementing the parameters, and writes a single UPDATE only
after the hash is complete, so no failure can leave an account with no
usable password. An unknown username is a hard error and never creates an
account.

It refuses to run against a DATA_DIR held by a live instance, via the
exclusive lock from #201. DATA_DIR and webhooker.db are checked to exist
before the lock is acquired, so a mistyped path creates nothing -- neither
a directory tree nor a stray lock file.

The bootstrap password now appears exactly once, in a distinct banner
written straight to a caller-named writer rather than as an fx log line.
This commit was merged in pull request #239.
This commit is contained in:
2026-08-20 08:01:42 +02:00
parent fcead5d401
commit 9969694a47
10 changed files with 1431 additions and 18 deletions

View File

@@ -0,0 +1,85 @@
package database_test
import (
"bytes"
"context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// passwordField is the banner line carrying the plaintext.
const passwordField = "password: "
// bannerPassword returns the password the banner printed.
func bannerPassword(t *testing.T, out string) string {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
_, value, found := strings.Cut(line, passwordField)
if found {
return strings.TrimSpace(value)
}
}
t.Fatalf("no %q line in the banner:\n%s", passwordField, out)
return ""
}
// TestFirstBoot_PrintsTheAdminPasswordAsABanner is the bootstrap half
// of https://git.eeqj.de/sneak/webhooker/issues/208.
//
// The password is shown exactly once, and it used to be shown as one
// slog record among the roughly 45 fx PROVIDE/RUN/HOOK lines a boot
// writes — which is how deployments lost it and, with no reset path,
// locked themselves out. It must be emitted as a block an operator can
// find by eye, it must carry the plaintext that actually opens the
// account, and it must name the command that recovers it.
func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
t.Parallel()
db, lc := setupTestDB(t)
var out bytes.Buffer
db.ExportSetBannerOut(&out)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
printed := out.String()
require.Contains(
t, printed, strings.Repeat("=", 20),
"the banner must be ruled off, not read as one more log line",
)
require.Contains(t, printed, "username: admin")
assert.Contains(
t, printed, "resetpw",
"the banner must name the command that recovers the account",
)
password := bannerPassword(t, printed)
require.NotEmpty(t, password)
// The printed plaintext must be the one that opens the account:
// a banner showing a different string would be worse than none.
var user database.User
require.NoError(
t,
db.DB().Where("username = ?", "admin").First(&user).Error,
)
ok, err := database.VerifyPassword(password, user.Password)
require.NoError(t, err)
assert.True(
t, ok, "the printed password must open the seeded account",
)
}