feat: seed admin/user account at install time via CLI (#750)

Signed-off-by: Alexander Fortin <shaftoe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Fortin
2026-06-29 18:23:54 +03:00
committed by GitHub
parent 95b7b4d6f2
commit c5d7832696
5 changed files with 407 additions and 0 deletions
+5
View File
@@ -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.
+5
View File
@@ -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
},
{
+61
View File
@@ -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"
```
+157
View File
@@ -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",
+179
View File
@@ -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"))
}