Add a webhooker resetpw subcommand and a bootstrap banner (closes #208)
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.
This commit is contained in:
2026-08-20 05:47:04 +00:00
parent aba02bc509
commit 29ce4cc429
10 changed files with 1431 additions and 18 deletions

View File

@@ -17,6 +17,7 @@ import (
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
"sneak.berlin/go/webhooker/internal/session"
)
@@ -48,6 +49,11 @@ import (
// and can still consume the whole budget on their own.
const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make
// sense of, kept distinct from the 1 a refusal exits with so that a
// caller can tell "called wrong" from "declined".
const exitUsage = 2
// Build-time variables set via -ldflags.
//
//nolint:gochecknoglobals // Build-time variables injected by the linker.
@@ -60,7 +66,54 @@ func main() {
globals.Appname = appname
globals.Version = version
os.Exit(run(os.Stderr))
os.Exit(dispatch(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
// dispatch routes the command line to a subcommand.
//
// No arguments runs the server, which is what the image's CMD and
// every existing deployment invoke; that path is unchanged, including
// where the DATA_DIR lock is taken relative to building the fx graph
// and how fx propagates a non-zero exit itself.
func dispatch(
args []string,
stdin io.Reader,
stdout, stderr io.Writer,
) int {
if len(args) == 0 {
return run(stderr)
}
switch args[0] {
case resetpw.Name:
return resetpw.Run(args[1:], stdin, stdout, stderr)
case "help", "-h", "-help", "--help":
usage(stdout)
return 0
default:
_, _ = fmt.Fprintf(
stderr, "%s: unknown subcommand %q\n", appname, args[0],
)
usage(stderr)
return exitUsage
}
}
// usage lists what the binary can be asked to do.
func usage(w io.Writer) {
_, _ = fmt.Fprintf(w, `usage: %s [subcommand]
With no subcommand, runs the webhooker server.
Subcommands:
%s [-generate] <username>
Set an existing account's password on a stopped deployment.
Recovers an admin account whose bootstrap password was lost.
help
Print this message.
`, appname, resetpw.Name)
}
// run takes the exclusive DATA_DIR lock, then runs the application

View File

@@ -2,12 +2,14 @@ package main
import (
"bytes"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
)
@@ -68,6 +70,65 @@ func TestRunRefusesLockedDataDir(t *testing.T) {
assert.Contains(t, stderr.String(), "another instance")
}
// TestDispatch_NoArgumentsRunsTheServer pins the routing of a bare
// invocation, which is what the image's CMD and every deployment use.
// Adding subcommands must not move the server off the empty argument
// list, and must not move the DATA_DIR lock: this asserts the refusal
// arrives with no fx graph built, exactly as run does on its own.
func TestDispatch_NoArgumentsRunsTheServer(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { _ = lock.Release() }()
var stdout, stderr bytes.Buffer
code := dispatch(nil, strings.NewReader(""), &stdout, &stderr)
require.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "another instance")
}
// TestDispatch_UnknownSubcommand keeps a mistyped subcommand from
// starting a server. Anything else would have `webhooker resetpww`
// silently take the DATA_DIR lock and serve.
func TestDispatch_UnknownSubcommand(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{"resetpww", "admin"},
strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 2, code)
assert.Contains(t, stderr.String(), "unknown subcommand")
assert.Contains(
t, stderr.String(), resetpw.Name,
"the usage must name the subcommand that does exist",
)
}
// TestDispatch_Help answers on standard output with a zero status, so
// `webhooker help` is usable in a pipe.
func TestDispatch_Help(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{"help"}, strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 0, code)
assert.Empty(t, stderr.String())
assert.Contains(t, stdout.String(), resetpw.Name)
}
// tailHeadroom is the slack the fx stop budget must keep beyond the
// server stop hook. The hooks that run after the server — the
// delivery engine, the healthcheck, the webhook DB manager and the