diff --git a/install.sh b/install.sh index 1b6a527..8a0433a 100755 --- a/install.sh +++ b/install.sh @@ -42,6 +42,7 @@ BUILD_ONLY=false CONFIG_ONLY=false UNINSTALL=false CHECK_STATUS=false +ADMIN_USER="" for arg in "$@"; do case "$arg" in @@ -50,8 +51,28 @@ for arg in "$@"; do --config-only) CONFIG_ONLY=true ;; --uninstall) UNINSTALL=true ;; --status) CHECK_STATUS=true ;; + --admin=*) ADMIN_USER="${arg#*=}" ;; + --admin) + error "Use --admin=username,email (e.g., --admin=cclohmar,claus@lohmar.co.uk)" + exit 1 + ;; --help) - head -11 "$0" | sed 's/^# //; 1s/.*/NextWks Installer/' | sed 's/^$/ /' + cat << 'HELPEOF' +NextWks Installer — Bare-metal deployment tool + + ./install.sh Build and install + ./install.sh --skip-build Install existing binary only + ./install.sh --build-only Compile only (no install) + ./install.sh --config-only Generate config only + ./install.sh --admin=user,email Create initial admin user + ./install.sh --status Check installation health + ./install.sh --uninstall Remove installation + +Examples: + ./install.sh Full build + install + ./install.sh --admin=cclohmar,cl@sechpoint.app Create admin during install + ./install.sh --status Check what's running +HELPEOF exit 0 ;; *) error "Unknown argument: $arg (use --help for options)"; exit 1 ;; @@ -293,7 +314,7 @@ NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ProtectHome=yes -ReadWritePaths=${DATA_DIR} ${MODULES_DIR} +ReadWritePaths=${DATA_DIR} ${MODULES_DIR} /opt/authelia/users_database.yml ReadOnlyPaths=${INSTALL_DIR}/config.yaml ${INSTALL_DIR}/static [Install] @@ -323,6 +344,63 @@ if [ "$CONFIG_ONLY" = false ]; then fi fi + # --- Admin User Creation --- + if [ "$CONFIG_ONLY" = false ]; then + # Read admin token from config + ADMIN_TOKEN=$(grep secret_token "$CONFIG_FILE" | head -1 | sed 's/.*: *"*//;s/"*$//' | xargs) + ADMIN_API="http://localhost:8080/admin/api/users" + + # Check if interactive or --admin flag was provided + if [ -t 0 ] && [ -z "$ADMIN_USER" ] && [ ! -f "$CONFIG_FILE.initialized" ]; then + echo "" + info "No --admin flag provided. Create an initial admin user?" + read -p "Enter username:email (or press Enter to skip): " ADMIN_INPUT + if [ -n "$ADMIN_INPUT" ]; then + ADMIN_USER="$ADMIN_INPUT" + fi + fi + + if [ -n "$ADMIN_USER" ]; then + # Parse username,email + ADMIN_UNAME="${ADMIN_USER%%,*}" + ADMIN_EMAIL="${ADMIN_USER#*,}" + if [ "$ADMIN_UNAME" = "$ADMIN_EMAIL" ]; then + ADMIN_EMAIL="" + fi + + info "Creating admin user: $ADMIN_UNAME..." + RESULT=$(curl -s -X POST "$ADMIN_API" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"users\":[{\"username\":\"$ADMIN_UNAME\",\"display_name\":\"$ADMIN_UNAME\",\"email\":\"$ADMIN_EMAIL\",\"role\":\"admin\",\"groups\":\"admins\"}]}") + + PASSWORD=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['results'][0].get('generated_password',''))" 2>/dev/null || echo "") + ERROR=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['results'][0].get('error',''))" 2>/dev/null || echo "") + + if [ -n "$PASSWORD" ]; then + success "Admin user created!" + echo "" + warn " ┌─────────────────────────────────────────┐" + warn " │ Username: $ADMIN_UNAME" + warn " │ Email: ${ADMIN_EMAIL:-}" + warn " │ Password: $PASSWORD" + warn " │ Groups: admins" + warn " └─────────────────────────────────────────┘" + echo "" + warn " Save this password! It cannot be recovered." + warn " User will be synced to Authelia automatically." + echo "" + elif [ -n "$ERROR" ]; then + warn "Admin creation failed: $ERROR" + else + warn "Could not parse response from admin API" + fi + fi + fi + + # Mark as initialized to skip interactive prompt next time + touch "$CONFIG_FILE.initialized" 2>/dev/null || true + kill $SMOKE_PID 2>/dev/null || true wait $SMOKE_PID 2>/dev/null || true fi diff --git a/src/core/admin/handlers.go b/src/core/admin/handlers.go index 6418ba9..4f2a472 100644 --- a/src/core/admin/handlers.go +++ b/src/core/admin/handlers.go @@ -2,6 +2,7 @@ package admin import ( "encoding/json" + "log/slog" "net/http" "strings" ) @@ -10,13 +11,15 @@ import ( type Handler struct { store *UserStore syncWriter *SyncWriter + logger *slog.Logger } // NewHandler creates a new admin Handler. -func NewHandler(store *UserStore, syncWriter *SyncWriter) *Handler { +func NewHandler(store *UserStore, syncWriter *SyncWriter, logger *slog.Logger) *Handler { return &Handler{ store: store, syncWriter: syncWriter, + logger: logger, } } @@ -63,8 +66,7 @@ func (h *Handler) createUsers(w http.ResponseWriter, r *http.Request) { // Sync to Authelia YAML if err := h.syncWriter.Sync(); err != nil { - // Log but don't fail - the users are in SQLite - // In production, you'd want to retry or alert + h.logger.Error("sync failed after create", "error", err) } writeJSON(w, http.StatusCreated, createUsersResponse{Results: results}) @@ -84,7 +86,7 @@ func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { // Sync to Authelia YAML if err := h.syncWriter.Sync(); err != nil { - // Log but don't fail + h.logger.Error("sync failed after delete", "error", err) } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "username": username}) diff --git a/src/core/admin/users.go b/src/core/admin/users.go index 6d1cca9..d6a266f 100644 --- a/src/core/admin/users.go +++ b/src/core/admin/users.go @@ -16,6 +16,7 @@ type User struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` + Role string `json:"role"` // "admin" or "user" Groups string `json:"groups"` PasswordHash string `json:"-"` Disabled bool `json:"disabled"` @@ -36,7 +37,7 @@ func NewUserStore(db *sql.DB) *UserStore { // List returns all non-deleted users. func (s *UserStore) List() ([]User, error) { rows, err := s.db.Query(` - SELECT id, username, display_name, email, groups, password_hash, disabled, created_at, updated_at + SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at FROM users ORDER BY username ASC `) if err != nil { @@ -47,7 +48,7 @@ func (s *UserStore) List() ([]User, error) { var users []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { return nil, fmt.Errorf("scan user: %w", err) } @@ -60,9 +61,9 @@ func (s *UserStore) List() ([]User, error) { func (s *UserStore) GetByUsername(username string) (*User, error) { var u User err := s.db.QueryRow(` - SELECT id, username, display_name, email, groups, password_hash, disabled, created_at, updated_at + SELECT id, username, display_name, email, role, groups, password_hash, disabled, created_at, updated_at FROM users WHERE username = ? - `, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, + `, username).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) if err == sql.ErrNoRows { return nil, nil @@ -83,6 +84,7 @@ type CreateUserInput struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` + Role string `json:"role"` // "admin" or "user" (default: "user") Groups string `json:"groups"` } @@ -126,10 +128,25 @@ func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult { // Hash password with argon2id hash := hashPassword(password) + // Default role to "user" if not set + if input.Role == "" { + input.Role = "user" + } + + // Build effective groups: role-based + explicit + effectiveGroups := input.Groups + if input.Role == "admin" { + if effectiveGroups == "" { + effectiveGroups = "admins" + } else if !containsGroup(effectiveGroups, "admins") { + effectiveGroups = effectiveGroups + ",admins" + } + } + _, err = s.db.Exec(` - INSERT INTO users (username, display_name, email, groups, password_hash, disabled, updated_at) - VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) - `, input.Username, input.DisplayName, input.Email, input.Groups, hash) + INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) + `, input.Username, input.DisplayName, input.Email, input.Role, effectiveGroups, hash) if err != nil { result.Error = fmt.Sprintf("insert failed: %v", err) results = append(results, result) @@ -163,6 +180,16 @@ func (s *UserStore) Count() (int, error) { return count, err } +// containsGroup checks if a comma-separated groups string contains a specific group. +func containsGroup(groups, target string) bool { + for _, g := range splitAndTrim(groups, ",") { + if g == target { + return true + } + } + return false +} + // generatePassword creates a cryptographically secure random password. func generatePassword(length int) (string, error) { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_" @@ -206,6 +233,7 @@ type SyncUser struct { Username string DisplayName string Email string + Role string Groups []string Password string Disabled bool @@ -229,6 +257,7 @@ func (s *UserStore) SyncSnapshot() ([]SyncUser, error) { Username: u.Username, DisplayName: u.DisplayName, Email: u.Email, + Role: u.Role, Groups: groups, Password: u.PasswordHash, Disabled: u.Disabled, diff --git a/src/core/db/db.go b/src/core/db/db.go index 23637b1..93f6252 100644 --- a/src/core/db/db.go +++ b/src/core/db/db.go @@ -61,12 +61,14 @@ func (d *Database) Migrate() error { func (d *Database) ensureTable(name string) error { switch name { case "users": + // Create table if it doesn't exist _, err := d.DB.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', groups TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, @@ -74,7 +76,13 @@ func (d *Database) ensureTable(name string) error { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) - return err + if err != nil { + return err + } + + // Migrate: add role column if missing (for existing databases) + d.DB.Exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`) + return nil case "sessions": _, err := d.DB.Exec(` diff --git a/src/main.go b/src/main.go index c361f77..2db9997 100644 --- a/src/main.go +++ b/src/main.go @@ -61,7 +61,7 @@ func main() { } // Create admin handler - adminHandler := admin.NewHandler(userStore, syncWriter) + adminHandler := admin.NewHandler(userStore, syncWriter, logger) // Initialize session store and OIDC auth sessionStore := auth.NewSessionStore(database.DB)