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
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:-<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
wait $SMOKE_PID 2>/dev/null || true
fi

View file

@ -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})

View file

@ -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,

View file

@ -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(`

View file

@ -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)