fix(sync): add error logging, fix systemd ReadWritePaths, add role field, interactive admin creation in installer

This commit is contained in:
Claus Lohmar 2026-06-14 14:11:38 +00:00
parent 34672eaedb
commit 0a1770acc4
5 changed files with 132 additions and 15 deletions

View file

@ -42,6 +42,7 @@ BUILD_ONLY=false
CONFIG_ONLY=false CONFIG_ONLY=false
UNINSTALL=false UNINSTALL=false
CHECK_STATUS=false CHECK_STATUS=false
ADMIN_USER=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
@ -50,8 +51,28 @@ for arg in "$@"; do
--config-only) CONFIG_ONLY=true ;; --config-only) CONFIG_ONLY=true ;;
--uninstall) UNINSTALL=true ;; --uninstall) UNINSTALL=true ;;
--status) CHECK_STATUS=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) --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 exit 0
;; ;;
*) error "Unknown argument: $arg (use --help for options)"; exit 1 ;; *) error "Unknown argument: $arg (use --help for options)"; exit 1 ;;
@ -293,7 +314,7 @@ NoNewPrivileges=yes
PrivateTmp=yes PrivateTmp=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes 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 ReadOnlyPaths=${INSTALL_DIR}/config.yaml ${INSTALL_DIR}/static
[Install] [Install]
@ -323,6 +344,63 @@ if [ "$CONFIG_ONLY" = false ]; then
fi fi
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:-<not set>}"
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 kill $SMOKE_PID 2>/dev/null || true
wait $SMOKE_PID 2>/dev/null || true wait $SMOKE_PID 2>/dev/null || true
fi fi

View file

@ -2,6 +2,7 @@ package admin
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"strings" "strings"
) )
@ -10,13 +11,15 @@ import (
type Handler struct { type Handler struct {
store *UserStore store *UserStore
syncWriter *SyncWriter syncWriter *SyncWriter
logger *slog.Logger
} }
// NewHandler creates a new admin Handler. // 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{ return &Handler{
store: store, store: store,
syncWriter: syncWriter, syncWriter: syncWriter,
logger: logger,
} }
} }
@ -63,8 +66,7 @@ func (h *Handler) createUsers(w http.ResponseWriter, r *http.Request) {
// Sync to Authelia YAML // Sync to Authelia YAML
if err := h.syncWriter.Sync(); err != nil { if err := h.syncWriter.Sync(); err != nil {
// Log but don't fail - the users are in SQLite h.logger.Error("sync failed after create", "error", err)
// In production, you'd want to retry or alert
} }
writeJSON(w, http.StatusCreated, createUsersResponse{Results: results}) 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 // Sync to Authelia YAML
if err := h.syncWriter.Sync(); err != nil { 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}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "username": username})

View file

@ -16,6 +16,7 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` // "admin" or "user"
Groups string `json:"groups"` Groups string `json:"groups"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
@ -36,7 +37,7 @@ func NewUserStore(db *sql.DB) *UserStore {
// List returns all non-deleted users. // List returns all non-deleted users.
func (s *UserStore) List() ([]User, error) { func (s *UserStore) List() ([]User, error) {
rows, err := s.db.Query(` 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 FROM users ORDER BY username ASC
`) `)
if err != nil { if err != nil {
@ -47,7 +48,7 @@ func (s *UserStore) List() ([]User, error) {
var users []User var users []User
for rows.Next() { for rows.Next() {
var u User 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 { &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan user: %w", err) 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) { func (s *UserStore) GetByUsername(username string) (*User, error) {
var u User var u User
err := s.db.QueryRow(` 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 = ? 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) &u.Groups, &u.PasswordHash, &u.Disabled, &u.CreatedAt, &u.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@ -83,6 +84,7 @@ type CreateUserInput struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Email string `json:"email"` Email string `json:"email"`
Role string `json:"role"` // "admin" or "user" (default: "user")
Groups string `json:"groups"` Groups string `json:"groups"`
} }
@ -126,10 +128,25 @@ func (s *UserStore) Create(req CreateUserRequest) []CreateUserResult {
// Hash password with argon2id // Hash password with argon2id
hash := hashPassword(password) 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(` _, err = s.db.Exec(`
INSERT INTO users (username, display_name, email, groups, password_hash, disabled, updated_at) INSERT INTO users (username, display_name, email, role, groups, password_hash, disabled, updated_at)
VALUES (?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP)
`, input.Username, input.DisplayName, input.Email, input.Groups, hash) `, input.Username, input.DisplayName, input.Email, input.Role, effectiveGroups, hash)
if err != nil { if err != nil {
result.Error = fmt.Sprintf("insert failed: %v", err) result.Error = fmt.Sprintf("insert failed: %v", err)
results = append(results, result) results = append(results, result)
@ -163,6 +180,16 @@ func (s *UserStore) Count() (int, error) {
return count, err 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. // generatePassword creates a cryptographically secure random password.
func generatePassword(length int) (string, error) { func generatePassword(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_"
@ -206,6 +233,7 @@ type SyncUser struct {
Username string Username string
DisplayName string DisplayName string
Email string Email string
Role string
Groups []string Groups []string
Password string Password string
Disabled bool Disabled bool
@ -229,6 +257,7 @@ func (s *UserStore) SyncSnapshot() ([]SyncUser, error) {
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Email: u.Email, Email: u.Email,
Role: u.Role,
Groups: groups, Groups: groups,
Password: u.PasswordHash, Password: u.PasswordHash,
Disabled: u.Disabled, Disabled: u.Disabled,

View file

@ -61,12 +61,14 @@ func (d *Database) Migrate() error {
func (d *Database) ensureTable(name string) error { func (d *Database) ensureTable(name string) error {
switch name { switch name {
case "users": case "users":
// Create table if it doesn't exist
_, err := d.DB.Exec(` _, err := d.DB.Exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL DEFAULT '', display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
groups TEXT NOT NULL DEFAULT '', groups TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
@ -74,7 +76,13 @@ func (d *Database) ensureTable(name string) error {
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`) `)
if err != nil {
return err 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": case "sessions":
_, err := d.DB.Exec(` _, err := d.DB.Exec(`

View file

@ -61,7 +61,7 @@ func main() {
} }
// Create admin handler // Create admin handler
adminHandler := admin.NewHandler(userStore, syncWriter) adminHandler := admin.NewHandler(userStore, syncWriter, logger)
// Initialize session store and OIDC auth // Initialize session store and OIDC auth
sessionStore := auth.NewSessionStore(database.DB) sessionStore := auth.NewSessionStore(database.DB)