Add a webhooker resetpw subcommand and a bootstrap banner (closes #208)
Some checks failed
check / check (push) Failing after 2m32s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user