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

47
internal/banner/banner.go Normal file
View File

@@ -0,0 +1,47 @@
// Package banner renders the operator-facing blocks that carry a
// plaintext credential.
//
// A generated password printed as one more structured log line is lost:
// a boot writes roughly 45 fx PROVIDE/RUN/HOOK lines around it, and
// under `docker run -d` it is one line in a log subject to rotation. A
// credential that is shown exactly once has to be findable by eye when
// an operator scrolls back, so it is written as a ruled block rather
// than as a log record.
//
// It is deliberately not a log line: it goes straight to the writer the
// caller names — standard output for both the first-boot account and
// the `resetpw` subcommand — so it is neither levelled, filtered, nor
// rendered as JSON by whichever handler internal/logger installed.
package banner
import (
"fmt"
"io"
"strings"
)
// ruleWidth is the length of the horizontal rules, chosen to fit an
// 80-column terminal without wrapping.
const ruleWidth = 72
// Credentials writes a ruled block naming an account and its plaintext
// password. headline says which event produced it, and note says what
// the operator must do about it; both are written verbatim, so a
// multi-line note must already be wrapped.
func Credentials(
w io.Writer,
headline, username, password, note string,
) error {
rule := strings.Repeat("=", ruleWidth)
_, err := fmt.Fprintf(
w,
"\n%s\n%s\n\n username: %s\n password: %s\n\n%s\n%s\n\n",
rule, headline, username, password, note, rule,
)
if err != nil {
return fmt.Errorf("writing credentials banner: %w", err)
}
return nil
}

View File

@@ -0,0 +1,59 @@
package banner_test
import (
"bytes"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/banner"
)
// TestCredentials_IsFindableByEye pins the properties that make the
// block worth having: rules above and below it, the two fields on
// their own lines, and blank lines separating it from whatever the
// surrounding log wrote.
func TestCredentials_IsFindableByEye(t *testing.T) {
t.Parallel()
var out bytes.Buffer
require.NoError(t, banner.Credentials(
&out, "HEADLINE", "admin", "s3cret", "NOTE",
))
got := out.String()
lines := strings.Split(strings.Trim(got, "\n"), "\n")
require.GreaterOrEqual(t, len(lines), 3)
assert.Equal(t, lines[0], lines[len(lines)-1], "rules must match")
assert.Greater(
t, len(lines[0]), 40, "the rule must be visible at a glance",
)
assert.Equal(t, strings.Repeat("=", len(lines[0])), lines[0])
assert.Contains(t, got, "\n username: admin\n")
assert.Contains(t, got, "\n password: s3cret\n")
assert.Contains(t, got, "HEADLINE")
assert.Contains(t, got, "NOTE")
assert.True(t, strings.HasPrefix(got, "\n"))
}
// failingWriter reports the write error a banner must not swallow: it
// is the one copy of a password that will never be shown again.
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, assert.AnError
}
func TestCredentials_ReportsAWriteFailure(t *testing.T) {
t.Parallel()
err := banner.Credentials(
failingWriter{}, "HEADLINE", "admin", "s3cret", "NOTE",
)
require.ErrorIs(t, err, assert.AnError)
}