fix(sync): add error logging, fix systemd ReadWritePaths, add role field, interactive admin creation in installer
This commit is contained in:
parent
34672eaedb
commit
0a1770acc4
5 changed files with 132 additions and 15 deletions
82
install.sh
82
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:-<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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue