Some checks failed
check / check (push) Failing after 2m32s
The bootstrap admin password was shown exactly once, as one INFO record among the roughly 45 fx lines a boot writes, and there was no reset path at all: no subcommand, no forgot-password flow, no override. Losing that line meant deleting the users row from webhooker.db by hand so the next start would re-seed. - internal/banner renders the one credential shown in the clear as a ruled block written straight to standard output, so it does not read as one more log line. The first boot emits the password there and nowhere else, and the banner names the recovery command. - `webhooker resetpw [-generate] <username>` sets an existing account's password. It reads the password as one line from standard input, or generates one with crypto/rand via the existing GenerateRandomPassword; it is never an argv value, which /proc would publish to every account on the host. Hashing goes through database.HashPassword, so the Argon2id parameters cannot drift. - It refuses to run against a DATA_DIR a live instance holds, by taking the same exclusive flock internal/datadir gives the server, and releases it when it finishes. - It creates nothing. A missing DATA_DIR, a directory with no webhooker.db, and an unknown username are each an error: datadir .Acquire calls os.MkdirAll, so a mistyped path would otherwise be built out and reported as a success. The existence checks therefore run before the lock is taken. - The account is resolved and the hash computed in full before the single UPDATE that stores it, so any failure leaves the stored credential untouched. - database.Open exposes the connect-and-migrate path without fx and without seeding; seeding moves to ensureAdminUser, which only a server start calls. - main gains subcommand dispatch. No arguments still runs the server on the same path, with the DATA_DIR lock taken before the fx graph is built and fx owning the non-zero exit; an unknown subcommand exits 2 rather than starting a server. Tests: reset then log in through the real form POST handler, the generated password verifying against the stored hash, the refusal against a held lock, both create-nothing cases, the unknown user, the unusable passwords, and the first-boot banner carrying a password that opens the account. README documents the bootstrap banner and the recovery command, including the container invocation and what resetpw will not do.
86 lines
2.2 KiB
Go
86 lines
2.2 KiB
Go
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",
|
|
)
|
|
}
|