mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user