Add a webhooker resetpw subcommand and a bootstrap banner (closes #208) (#239)
All checks were successful
check / check (push) Successful in 3m25s
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:
85
internal/database/bootstrap_banner_test.go
Normal file
85
internal/database/bootstrap_banner_test.go
Normal 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",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user