From c5d7832696164a66a23a2cf72642453ab8071c85 Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Mon, 29 Jun 2026 18:23:54 +0300 Subject: [PATCH] feat: seed admin/user account at install time via CLI (#750) Signed-off-by: Alexander Fortin Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 + docs/.vitepress/config.mts | 5 + docs/administration/create-user.md | 61 ++++++++++ internal/cli/admin.go | 157 +++++++++++++++++++++++++ internal/cli/admin_test.go | 179 +++++++++++++++++++++++++++++ 5 files changed, 407 insertions(+) create mode 100644 docs/administration/create-user.md create mode 100644 internal/cli/admin_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0e180..e00807e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Added +- Seed a user/admin account at install time from the CLI (`admin create-user`), enabling automated provisioning with tools like Ansible (#1) + ## [1.13.1](https://github.com/thomiceli/opengist/compare/v1.13.0...v1.13.1) - 2026-06-10 See here how to [update](https://opengist.io/docs/update) Opengist. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7fc2956..f2db98d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -97,6 +97,11 @@ const docsSidebar = [ ], collapsed: true}, {text: 'Fail2ban', link: '/fail2ban-setup'}, {text: 'Healthcheck', link: '/healthcheck'}, + {text: 'Manage users & admins', items: [ + {text: 'Seed a user (CLI)', link: '/create-user'}, + {text: 'Reset a password', link: '/reset-password'}, + {text: 'Manage admins', link: '/manage-admins'}, + ], collapsed: true}, ], collapsed: false }, { diff --git a/docs/administration/create-user.md b/docs/administration/create-user.md new file mode 100644 index 0000000..574f179 --- /dev/null +++ b/docs/administration/create-user.md @@ -0,0 +1,61 @@ +# Seed a user account (CLI) + +When deploying Opengist in an automated way (Ansible, Terraform, a bootstrap script, …), you usually want an administrator account to exist right after installation, without having to go through the web setup form. The `admin create-user` command does exactly that. + +## Usage + +```bash +./opengist admin create-user [options] +``` + +| Option | Description | +|--------------------|------------------------------------------------------------------------------------------------------| +| `--username`, `-u` | Username of the new user (same validation rules as the web form: alphanumerics and dashes, ≤ 24). | +| `--password`, `-p` | Password for the new user. Note: visible in the process list, so prefer `--password-stdin` for automation. | +| `--password-stdin` | Read the password from the first line of stdin. Keeps the secret out of the process list and shell history. | +| `--email` | Optional email address (enables [Gravatar](https://gravatar.com)). | +| `--admin` | Grant administrator privileges to the new user. | + +All inputs are flags (including the username). Flags may be given in any order. + +## Seeding an admin + +```bash +$ ./opengist admin create-user --username admin --password 's3cret!' --admin --email admin@example.com +User admin has been created with administrator privileges. +``` + +Reading the password from stdin is recommended for automation, so it never appears in the process list or your playbook: + +```bash +$ printf '%s' "$OPENGIST_ADMIN_PASSWORD" | ./opengist admin create-user --username admin --password-stdin --admin +User admin has been created with administrator privileges. +``` + +### Idempotency + +The command is idempotent: if the user already exists it does nothing and exits successfully (`0`). This makes it safe to run repeatedly from a provisioning playbook. + +```bash +$ ./opengist admin create-user --username admin --password 's3cret!' --admin +User admin already exists; nothing to do. +``` + +::: tip +`create-user` only creates accounts. To change an existing user's password use [`admin reset-password`](./reset-password), and to grant or revoke admin rights use [`admin toggle-admin`](./manage-admins). +::: + +## Ansible example + +```yaml +- name: Ensure the Opengist admin user exists + shell: | + printf '%s' '{{ opengist_admin_password }}' | \ + {{ opengist_binary }} admin create-user \ + --username {{ opengist_admin_user }} \ + --password-stdin \ + {% if opengist_admin_email %}--email {{ opengist_admin_email }}{% endif %} \ + --admin + register: seed + changed_when: "'has been created' in seed.stdout" +``` diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 2bd5003..10d30b1 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -1,16 +1,30 @@ package cli import ( + "bufio" + "crypto/md5" "fmt" "io" + "os" + "strings" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/auth/password" "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/validator" "github.com/urfave/cli/v2" ) +// cliError prints a message to stderr and returns it as an error, so that +// user-facing validation/guidance failures are visible even though main() only +// acts on the presence of an error (os.Exit(1)) without printing it. +func cliError(format string, a ...any) error { + err := fmt.Errorf(format, a...) + fmt.Fprintln(os.Stderr, "Error:", err) + return err +} + func initialize(ctx *cli.Context) { if err := config.InitConfig(ctx.String("config"), io.Discard); err != nil { panic(err) @@ -27,11 +41,154 @@ var CmdAdmin = cli.Command{ Name: "admin", Usage: "Admin commands", Subcommands: []*cli.Command{ + &CmdAdminCreateUser, &CmdAdminResetPassword, &CmdAdminToggleAdmin, }, } +// CmdAdminCreateUser creates a user from the CLI. Its primary purpose is to +// seed an account (typically an administrator) at install time, so that +// deployment can be fully automated with tools like Ansible, without having +// to interact with the web setup form. +// +// The command is idempotent: if the user already exists it does nothing and +// exits successfully, making it safe to re-run from a provisioning playbook. +// It only ever creates users — promoting an existing user or changing its +// password is the job of toggle-admin / reset-password. +// +// All inputs are flags (including --username). Unlike the sibling +// reset-password/toggle-admin commands which take a positional username, +// create-user avoids a positional argument on purpose: urfave/cli's +// default-command resolution would otherwise misroute the invocation when a +// positional value happens to match a flag name (e.g. a user named "admin" +// combined with --admin). +var CmdAdminCreateUser = cli.Command{ + Name: "create-user", + Usage: "Create a new user (useful to seed an admin account at install time)", + ArgsUsage: "[command options]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "Username of the new user", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Password for the new user (exposed in the process list; prefer --password-stdin for automation)", + }, + &cli.BoolFlag{ + Name: "password-stdin", + Usage: "Read the password from stdin", + }, + &cli.StringFlag{ + Name: "email", + Usage: "Email address of the new user", + }, + &cli.BoolFlag{ + Name: "admin", + Usage: "Grant administrator privileges to the new user", + }, + }, + Action: func(ctx *cli.Context) error { + initialize(ctx) + + username := ctx.String("username") + if username == "" { + if ctx.NArg() > 0 { + return cliError("create-user takes flags, not a positional argument; pass the username via --username (e.g. opengist admin create-user --username %s ...)", ctx.Args().Get(0)) + } + return cliError("username is required (use --username)") + } + + // Validate the username using the same rules as the web registration + // form (alphanumerics and dashes, max 24 chars, no reserved names). + v := validator.NewValidator() + if err := v.Var(username, "required,max=24,alphanumdash,notreserved"); err != nil { + fmt.Fprintf(os.Stderr, "Error: invalid username: %s\n", err) + return err + } + + plainPassword, err := resolveCreateUserPassword(ctx) + if err != nil { + return cliError("%s", err) + } + if plainPassword == "" { + return cliError("password is required (use --password or --password-stdin)") + } + + // Idempotent: provisioning tools may re-run this command, so a no-op on + // an existing user is preferred over an error. + exists, err := db.UserExists(username) + if err != nil { + fmt.Printf("Cannot check if user %s exists: %s\n", username, err) + return err + } + if exists { + fmt.Printf("User %s already exists; nothing to do.\n", username) + return nil + } + + email := strings.ToLower(strings.TrimSpace(ctx.String("email"))) + + hashedPassword, err := password.HashPassword(plainPassword) + if err != nil { + fmt.Printf("Cannot hash password for user %s: %s\n", username, err) + return err + } + + user := &db.User{ + Username: username, + Password: hashedPassword, + Email: email, + MD5Hash: gravatarHash(email), + IsAdmin: ctx.Bool("admin"), + } + + if err = user.Create(); err != nil { + fmt.Printf("Cannot create user %s: %s\n", username, err) + return err + } + + fmt.Printf("User %s has been created", username) + if user.IsAdmin { + fmt.Print(" with administrator privileges") + } + fmt.Println(".") + return nil + }, +} + +// resolveCreateUserPassword returns the password from the --password flag, or +// reads it from stdin when --password-stdin is set. The two sources are +// mutually exclusive. Reading from stdin keeps the secret out of the process +// list and shell history, which is preferred for automated provisioning. +func resolveCreateUserPassword(ctx *cli.Context) (string, error) { + if ctx.Bool("password-stdin") { + if ctx.String("password") != "" { + return "", fmt.Errorf("--password and --password-stdin are mutually exclusive") + } + reader := bufio.NewReader(os.Stdin) + bytes, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", fmt.Errorf("cannot read password from stdin: %w", err) + } + return strings.TrimRight(bytes, "\r\n"), nil + } + return ctx.String("password"), nil +} + +// gravatarHash computes the gravatar key the same way the web flows do, so an +// avatar resolves identically regardless of how the account was created. With +// no email the hash is left empty, matching the password-registration flow. +func gravatarHash(email string) string { + if email == "" { + return "" + } + return fmt.Sprintf("%x", md5.Sum([]byte(email))) +} + var CmdAdminResetPassword = cli.Command{ Name: "reset-password", Usage: "Reset the password for a given user", diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go new file mode 100644 index 0000000..7057d07 --- /dev/null +++ b/internal/cli/admin_test.go @@ -0,0 +1,179 @@ +package cli + +import ( + "crypto/md5" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/auth/password" + "github.com/thomiceli/opengist/internal/db" + "github.com/urfave/cli/v2" +) + +// runCreateUser runs `admin create-user` against an isolated, temporary +// Opengist home so the command's own initialize() connects to a throwaway +// SQLite database. It returns whatever error the command produced. +func runCreateUser(t *testing.T, args ...string) error { + t.Helper() + + home := t.TempDir() + // InitLog (called by initialize) creates a "log" subdir under home. + require.NoError(t, os.MkdirAll(filepath.Join(home, "log"), 0755)) + t.Setenv("OG_OPENGIST_HOME", home) + + t.Cleanup(func() { + _ = db.Close() + }) + + app := &cli.App{ + Name: "opengist", + Commands: []*cli.Command{ + {Name: "start", Action: func(*cli.Context) error { return nil }}, + {Name: "admin", Subcommands: []*cli.Command{&CmdAdminCreateUser}}, + }, + DefaultCommand: "start", + } + return app.Run(append([]string{"opengist", "admin", "create-user"}, args...)) +} + +func TestCmdAdminCreateUser_BasicAdmin(t *testing.T) { + require.NoError(t, runCreateUser(t, "--username", "admin", "--password", "s3cret!", "--admin", "--email", "admin@example.com")) + + user, err := db.GetUserByUsername("admin") + require.NoError(t, err) + require.True(t, user.IsAdmin, "user should be an admin") + require.Equal(t, "admin@example.com", user.Email) + require.NotEmpty(t, user.Password) + + match, err := password.VerifyPassword("s3cret!", user.Password) + require.NoError(t, err) + require.True(t, match, "password should verify") + + // Gravatar hash must match the lowercased email, like the web flows. + require.Equal(t, fmt.Sprintf("%x", md5.Sum([]byte("admin@example.com"))), user.MD5Hash) +} + +// TestCmdAdminCreateUser_AdminNamedAdmin is the critical regression case: a +// user literally named "admin" combined with the --admin flag used to trigger +// urfave/cli's default-command misrouting. With the all-flags design there is +// no positional argument, so this must create the user correctly. +func TestCmdAdminCreateUser_AdminNamedAdmin(t *testing.T) { + require.NoError(t, runCreateUser(t, "--username", "admin", "--password", "pw", "--admin")) + + user, err := db.GetUserByUsername("admin") + require.NoError(t, err) + require.True(t, user.IsAdmin) + + match, err := password.VerifyPassword("pw", user.Password) + require.NoError(t, err) + require.True(t, match) +} + +func TestCmdAdminCreateUser_NotAdminByDefault(t *testing.T) { + require.NoError(t, runCreateUser(t, "--username", "bob", "-p", "password123")) + + user, err := db.GetUserByUsername("bob") + require.NoError(t, err) + require.False(t, user.IsAdmin) + require.Empty(t, user.Email) + require.Empty(t, user.MD5Hash, "no email => no gravatar hash, matching registration") +} + +func TestCmdAdminCreateUser_FlagsInAnyOrder(t *testing.T) { + // Flags may appear in any order; there is no positional argument. + require.NoError(t, runCreateUser(t, "--admin", "--password", "pw", "--username", "zoe")) + + user, err := db.GetUserByUsername("zoe") + require.NoError(t, err) + require.True(t, user.IsAdmin) +} + +func TestCmdAdminCreateUser_ReRunIsNoOp(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(home, "log"), 0755)) + t.Setenv("OG_OPENGIST_HOME", home) + t.Cleanup(func() { _ = db.Close() }) + + run := func() error { + app := &cli.App{ + Name: "opengist", + Commands: []*cli.Command{ + {Name: "start", Action: func(*cli.Context) error { return nil }}, + {Name: "admin", Subcommands: []*cli.Command{&CmdAdminCreateUser}}, + }, + DefaultCommand: "start", + } + return app.Run([]string{"opengist", "admin", "create-user", "--username", "seed", "--password", "pw", "--admin"}) + } + + require.NoError(t, run(), "first run should create the user") + require.NoError(t, run(), "second run should be a no-op, not an error") + + count, err := db.CountAll(&db.User{}) + require.NoError(t, err) + require.Equal(t, int64(1), count, "idempotent run must not duplicate the user") +} + +func TestCmdAdminCreateUser_MissingUsername(t *testing.T) { + err := runCreateUser(t, "--password", "pw") + require.Error(t, err) +} + +func TestCmdAdminCreateUser_MissingPassword(t *testing.T) { + err := runCreateUser(t, "--username", "nopass") + require.Error(t, err) +} + +func TestCmdAdminCreateUser_InvalidUsername(t *testing.T) { + // Reserved names must be rejected with the same rules as the web form. + err := runCreateUser(t, "--username", "login", "--password", "pw") + require.Error(t, err) + + _, fetchErr := db.GetUserByUsername("login") + require.Error(t, fetchErr, "reserved user must not be persisted") +} + +// TestCmdAdminCreateUser_PositionalArgGivesGuidance checks that a user who +// tries the positional style (as the sibling commands use) gets a helpful +// pointer to --username rather than a confusing failure. +func TestCmdAdminCreateUser_PositionalArgGivesGuidance(t *testing.T) { + err := runCreateUser(t, "alice", "--password", "pw") + require.Error(t, err) + require.Contains(t, err.Error(), "--username") +} + +func TestCmdAdminCreateUser_PasswordStdin(t *testing.T) { + // Feed the password through stdin to keep it out of the process list. + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.Write([]byte("stdin-secret\n")) + require.NoError(t, err) + require.NoError(t, w.Close()) + + oldStdin := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = oldStdin }) + + require.NoError(t, runCreateUser(t, "--username", "pipe", "--password-stdin")) + + user, err := db.GetUserByUsername("pipe") + require.NoError(t, err) + match, err := password.VerifyPassword("stdin-secret", user.Password) + require.NoError(t, err) + require.True(t, match) +} + +func TestCmdAdminCreateUser_PasswordStdinAndFlagMutuallyExclusive(t *testing.T) { + err := runCreateUser(t, "--username", "x", "--password", "pw", "--password-stdin") + require.Error(t, err) +} + +func TestGravatarHash(t *testing.T) { + require.Empty(t, gravatarHash("")) + require.Equal(t, fmt.Sprintf("%x", md5.Sum([]byte("User@Example.com"))), gravatarHash("User@Example.com")) + // A 32-char lowercase hex digest. + require.Regexp(t, `^[0-9a-f]{32}$`, gravatarHash("a@b.c")) +}