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:
@@ -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