Files
warmbly/cmd/warmblyctl/password.go
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

123 lines
3.9 KiB
Go

package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/warmbly/warmbly/internal/pkg/crypt"
)
// One reader for the whole process: a fresh bufio.Reader per prompt would eat
// the second password out of its buffer.
var stdin = bufio.NewReader(os.Stdin)
// errNoTerminal is the refusal that keeps a passwordless account from existing.
var errNoTerminal = errors.New("stdin is not a terminal, so there is nowhere to prompt for a password, and an account nobody can sign in to is worse than no account.\nPipe the password in instead:\n printf '%s' 'your-password' | docker compose -p warmbly exec -T backend warmblyctl ... --password-stdin")
// readPassword takes the password from stdin when --password-stdin is set, and
// otherwise prompts twice on a terminal. A non-TTY without --password-stdin is
// refused rather than silently creating an account nobody can sign in to.
func readPassword(ctx context.Context, fromStdin bool, what string) (string, error) {
if fromStdin {
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("reading the password from stdin: %w", err)
}
password := strings.TrimRight(string(data), "\r\n")
if password == "" {
return "", errors.New("--password-stdin was set but stdin was empty. Pipe the password in:\n printf '%s' 'your-password' | docker compose -p warmbly exec -T backend warmblyctl ... --password-stdin")
}
return password, nil
}
if !stdinIsTerminal(ctx) {
return "", errNoTerminal
}
first, err := promptSecret(ctx, what+": ")
if err != nil {
return "", err
}
second, err := promptSecret(ctx, "Repeat "+strings.ToLower(what)+": ")
if err != nil {
return "", err
}
if first != second {
return "", errors.New("the two passwords do not match, so nothing was changed. Run the command again.")
}
return first, nil
}
// validatePassword uses the same rule the dashboard enforces, so the scripted
// route is never weaker than the interactive one.
func validatePassword(password string) error {
if crypt.ValidatePassword(password) {
return nil
}
return errors.New("that password is not accepted: it must be between 8 and 128 characters. Nothing was changed.")
}
func promptSecret(ctx context.Context, label string) (string, error) {
fmt.Fprint(os.Stderr, label)
restore, err := disableEcho(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "\nWarning: terminal echo could not be turned off, so the password will be visible as you type.")
fmt.Fprint(os.Stderr, label)
} else {
defer restore()
}
line, rerr := stdin.ReadString('\n')
fmt.Fprintln(os.Stderr)
if rerr != nil && line == "" {
// Nothing to read means nobody is typing, whatever the terminal test said.
if errors.Is(rerr, io.EOF) {
return "", errNoTerminal
}
return "", fmt.Errorf("reading the password: %w", rerr)
}
return strings.TrimRight(line, "\r\n"), nil
}
// stdinIsTerminal reports whether a person is on the other end. The
// character-device test alone answers yes for /dev/null, so stty confirms it:
// docker compose exec allocates a TTY unless -T is passed.
func stdinIsTerminal(ctx context.Context) bool {
info, err := os.Stdin.Stat()
if err != nil || info.Mode()&os.ModeCharDevice == 0 {
return false
}
if _, err := exec.LookPath("stty"); err != nil {
// No stty to ask. The prompt itself fails closed on EOF.
return true
}
return stty(ctx, "-g") == nil
}
// disableEcho shells out to stty (present in the alpine runtime image via
// busybox) because turning echo off in pure Go needs per-platform ioctls.
func disableEcho(ctx context.Context) (func(), error) {
if _, err := exec.LookPath("stty"); err != nil {
return nil, err
}
if err := stty(ctx, "-echo"); err != nil {
return nil, err
}
return func() { _ = stty(ctx, "echo") }, nil
}
func stty(ctx context.Context, arg string) error {
cmd := exec.CommandContext(ctx, "stty", arg)
cmd.Stdin = os.Stdin
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd.Run()
}