Phase 1: first-run setup wizard, bash installer, Docker profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+28
-2
@@ -40,7 +40,7 @@ from managers import (
|
||||
network_manager, wireguard_manager, peer_registry,
|
||||
email_manager, calendar_manager, file_manager,
|
||||
routing_manager, vault_manager, container_manager,
|
||||
cell_link_manager, auth_manager,
|
||||
cell_link_manager, auth_manager, setup_manager,
|
||||
firewall_manager, EventType,
|
||||
)
|
||||
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
|
||||
@@ -158,6 +158,28 @@ def enrich_log_context():
|
||||
'user': user
|
||||
})
|
||||
|
||||
@app.before_request
|
||||
def enforce_setup():
|
||||
"""Block API requests until the first-run wizard has been completed.
|
||||
|
||||
The setup routes, /health, and all non-/api/ paths are always allowed
|
||||
through. Any other /api/* request while setup is incomplete receives
|
||||
a 428 with a redirect hint to /setup.
|
||||
|
||||
Skipped entirely when app.config['TESTING'] is True so unit tests remain
|
||||
unaffected without needing to mark setup as complete.
|
||||
"""
|
||||
if app.config.get('TESTING'):
|
||||
return None
|
||||
path = request.path
|
||||
if (path.startswith('/api/setup') or
|
||||
path == '/health' or
|
||||
not path.startswith('/api/')):
|
||||
return None
|
||||
if not setup_manager.is_setup_complete():
|
||||
return jsonify({'error': 'Setup required', 'redirect': '/setup'}), 428
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_auth():
|
||||
"""Enforce session-based authentication and role-based access control.
|
||||
@@ -232,7 +254,7 @@ def check_csrf():
|
||||
if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
|
||||
return None
|
||||
path = request.path
|
||||
if not path.startswith('/api/') or path.startswith('/api/auth/'):
|
||||
if not path.startswith('/api/') or path.startswith('/api/auth/') or path.startswith('/api/setup/'):
|
||||
return None
|
||||
# peer-sync uses IP+pubkey auth — no session, no CSRF token possible
|
||||
if path.startswith('/api/cells/peer-sync/'):
|
||||
@@ -409,6 +431,10 @@ service_bus.register_service('container', container_manager)
|
||||
# Register auth blueprint
|
||||
app.register_blueprint(auth_routes.auth_bp)
|
||||
|
||||
# Register setup blueprint (no auth required — runs before any account exists)
|
||||
from routes.setup import setup_bp
|
||||
app.register_blueprint(setup_bp)
|
||||
|
||||
# Register service blueprints (routes extracted from this file)
|
||||
from routes.email import bp as _email_bp
|
||||
from routes.calendar import bp as _calendar_bp
|
||||
|
||||
+15
-1
@@ -37,6 +37,9 @@ class ConfigManager:
|
||||
pass
|
||||
self.service_schemas = self._load_service_schemas()
|
||||
self.configs = self._load_all_configs()
|
||||
# Ensure _identity key always exists
|
||||
if '_identity' not in self.configs:
|
||||
self.configs['_identity'] = {}
|
||||
if not self.config_file.exists():
|
||||
self._save_all_configs()
|
||||
|
||||
@@ -460,10 +463,21 @@ class ConfigManager:
|
||||
# No-op for unified config, but keep for compatibility
|
||||
pass
|
||||
|
||||
def get_identity(self) -> Dict[str, Any]:
|
||||
"""Return the current identity configuration."""
|
||||
return self.configs.get('_identity', {})
|
||||
|
||||
def set_identity_field(self, key: str, value: Any):
|
||||
"""Set a single field in the identity configuration and persist."""
|
||||
if '_identity' not in self.configs:
|
||||
self.configs['_identity'] = {}
|
||||
self.configs['_identity'][key] = value
|
||||
self._save_all_configs()
|
||||
|
||||
def get_all_configs(self) -> Dict[str, Dict]:
|
||||
"""Get all service configurations"""
|
||||
return self.configs.copy()
|
||||
|
||||
|
||||
def get_config_summary(self) -> Dict[str, Any]:
|
||||
"""Get summary of all configurations"""
|
||||
summary = {
|
||||
|
||||
+3
-1
@@ -27,6 +27,7 @@ from log_manager import LogManager
|
||||
from cell_link_manager import CellLinkManager
|
||||
import firewall_manager
|
||||
from auth_manager import AuthManager
|
||||
from setup_manager import SetupManager
|
||||
|
||||
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
||||
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
|
||||
@@ -53,6 +54,7 @@ cell_link_manager = CellLinkManager(
|
||||
network_manager=network_manager,
|
||||
)
|
||||
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
|
||||
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager)
|
||||
|
||||
# Service logger configuration
|
||||
_service_log_configs = {
|
||||
@@ -86,7 +88,7 @@ __all__ = [
|
||||
'network_manager', 'wireguard_manager', 'peer_registry',
|
||||
'email_manager', 'calendar_manager', 'file_manager',
|
||||
'routing_manager', 'vault_manager', 'container_manager',
|
||||
'cell_link_manager', 'auth_manager',
|
||||
'cell_link_manager', 'auth_manager', 'setup_manager',
|
||||
'firewall_manager', 'EventType',
|
||||
'DATA_DIR', 'CONFIG_DIR',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import logging
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
logger = logging.getLogger('picell')
|
||||
|
||||
setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup')
|
||||
|
||||
|
||||
def _get_setup_manager():
|
||||
from app import setup_manager
|
||||
return setup_manager
|
||||
|
||||
|
||||
@setup_bp.route('/status', methods=['GET'])
|
||||
def get_setup_status():
|
||||
"""Return wizard status and available options."""
|
||||
sm = _get_setup_manager()
|
||||
if sm.is_setup_complete():
|
||||
return jsonify({'error': 'Setup already complete'}), 410
|
||||
return jsonify(sm.get_setup_status())
|
||||
|
||||
|
||||
@setup_bp.route('/validate', methods=['POST'])
|
||||
def validate_setup_step():
|
||||
"""Validate a single wizard step.
|
||||
|
||||
Expects JSON body: ``{'step': '<step_name>', 'data': {...}}``.
|
||||
Supported steps: ``cell_name``, ``password``.
|
||||
"""
|
||||
sm = _get_setup_manager()
|
||||
if sm.is_setup_complete():
|
||||
return jsonify({'error': 'Setup already complete'}), 410
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
step = body.get('step', '')
|
||||
data = body.get('data', {})
|
||||
|
||||
if step == 'cell_name':
|
||||
errors = sm.validate_cell_name(data.get('cell_name', ''))
|
||||
elif step == 'password':
|
||||
errors = sm.validate_password(data.get('password', ''))
|
||||
else:
|
||||
return jsonify({'valid': False, 'errors': [f"Unknown step: {step!r}"]}), 400
|
||||
|
||||
return jsonify({'valid': len(errors) == 0, 'errors': errors})
|
||||
|
||||
|
||||
@setup_bp.route('/complete', methods=['POST'])
|
||||
def complete_setup():
|
||||
"""Complete the first-run wizard and create the admin account."""
|
||||
sm = _get_setup_manager()
|
||||
if sm.is_setup_complete():
|
||||
return jsonify({'error': 'Setup already complete'}), 410
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
result = sm.complete_setup(payload)
|
||||
status_code = 200 if result.get('success') else 400
|
||||
return jsonify(result), status_code
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SetupManager — first-run wizard backend for PIC.
|
||||
|
||||
Handles validation, locking, and atomic completion of the initial setup
|
||||
wizard. Called by api/routes/setup.py.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Top 30 representative IANA time zones shown in the wizard
|
||||
AVAILABLE_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Honolulu',
|
||||
'America/Sao_Paulo',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Mexico_City',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Madrid',
|
||||
'Europe/Rome',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Moscow',
|
||||
'Europe/Istanbul',
|
||||
'Africa/Cairo',
|
||||
'Africa/Johannesburg',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Seoul',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
]
|
||||
|
||||
AVAILABLE_SERVICES = [
|
||||
'email',
|
||||
'calendar',
|
||||
'files',
|
||||
'wireguard',
|
||||
]
|
||||
|
||||
VALID_DOMAIN_MODES = {'pic_ngo', 'custom', 'lan'}
|
||||
|
||||
CELL_NAME_RE = re.compile(r'^[a-z][a-z0-9-]{1,30}$')
|
||||
|
||||
|
||||
class SetupManager:
|
||||
"""Manages the first-run setup wizard state and completion."""
|
||||
|
||||
def __init__(self, config_manager, auth_manager):
|
||||
self.config_manager = config_manager
|
||||
self.auth_manager = auth_manager
|
||||
|
||||
# ── state helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def is_setup_complete(self) -> bool:
|
||||
"""Return True if setup has already been completed."""
|
||||
return bool(self.config_manager.get_identity().get('setup_complete', False))
|
||||
|
||||
def get_setup_status(self) -> Dict[str, Any]:
|
||||
"""Return current setup status and wizard metadata."""
|
||||
return {
|
||||
'complete': self.is_setup_complete(),
|
||||
'available_services': AVAILABLE_SERVICES,
|
||||
'available_timezones': AVAILABLE_TIMEZONES,
|
||||
}
|
||||
|
||||
# ── validation ────────────────────────────────────────────────────────
|
||||
|
||||
def validate_cell_name(self, name: str) -> List[str]:
|
||||
"""Validate a proposed cell name. Returns a list of error strings."""
|
||||
errors: List[str] = []
|
||||
if not name:
|
||||
errors.append('Cell name is required.')
|
||||
return errors
|
||||
if not CELL_NAME_RE.match(name):
|
||||
errors.append(
|
||||
'Cell name must start with a lowercase letter, be 2–31 characters, '
|
||||
'and contain only lowercase letters, digits, and hyphens.'
|
||||
)
|
||||
if name.startswith('-') or name.endswith('-'):
|
||||
errors.append('Cell name must not start or end with a hyphen.')
|
||||
return errors
|
||||
|
||||
def validate_password(self, password: str) -> List[str]:
|
||||
"""Validate admin password strength. Returns a list of error strings."""
|
||||
errors: List[str] = []
|
||||
if not password:
|
||||
errors.append('Password is required.')
|
||||
return errors
|
||||
if len(password) < 12:
|
||||
errors.append('Password must be at least 12 characters long.')
|
||||
if not re.search(r'[A-Z]', password):
|
||||
errors.append('Password must contain at least one uppercase letter.')
|
||||
if not re.search(r'[a-z]', password):
|
||||
errors.append('Password must contain at least one lowercase letter.')
|
||||
if not re.search(r'\d', password):
|
||||
errors.append('Password must contain at least one digit.')
|
||||
return errors
|
||||
|
||||
# ── main completion ───────────────────────────────────────────────────
|
||||
|
||||
def complete_setup(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run all validation, then atomically complete the setup wizard.
|
||||
|
||||
Returns ``{'success': True, 'redirect': '/login'}`` on success or
|
||||
``{'success': False, 'errors': [...]}`` on any failure.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
# ── validate inputs ────────────────────────────────────────────────
|
||||
cell_name = payload.get('cell_name', '')
|
||||
password = payload.get('password', '')
|
||||
domain_mode = payload.get('domain_mode', '')
|
||||
timezone = payload.get('timezone', '')
|
||||
services_enabled = payload.get('services_enabled', [])
|
||||
ddns_provider = payload.get('ddns_provider', 'none')
|
||||
|
||||
errors.extend(self.validate_cell_name(cell_name))
|
||||
errors.extend(self.validate_password(password))
|
||||
|
||||
if domain_mode not in VALID_DOMAIN_MODES:
|
||||
errors.append(
|
||||
f"domain_mode must be one of: {', '.join(sorted(VALID_DOMAIN_MODES))}."
|
||||
)
|
||||
if not timezone or not isinstance(timezone, str):
|
||||
errors.append('timezone is required.')
|
||||
if not isinstance(services_enabled, list):
|
||||
errors.append('services_enabled must be a list.')
|
||||
|
||||
if errors:
|
||||
return {'success': False, 'errors': errors}
|
||||
|
||||
# ── acquire file lock to prevent double-completion ─────────────────
|
||||
lock_path = os.path.join(
|
||||
os.environ.get('DATA_DIR', '/app/data'), 'api', '.setup.lock'
|
||||
)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
lock_fd = open(lock_path, 'w')
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
except OSError as exc:
|
||||
logger.error(f'Could not acquire setup lock: {exc}')
|
||||
return {'success': False, 'errors': ['Setup lock could not be acquired. Try again.']}
|
||||
|
||||
try:
|
||||
# Re-check inside lock
|
||||
if self.is_setup_complete():
|
||||
return {'success': False, 'errors': ['Setup has already been completed.']}
|
||||
|
||||
# ── create admin user ──────────────────────────────────────────
|
||||
ok = self.auth_manager.create_user(
|
||||
username='admin',
|
||||
password=password,
|
||||
role='admin',
|
||||
)
|
||||
if not ok:
|
||||
return {'success': False, 'errors': ['Failed to create admin user. The username may already exist.']}
|
||||
|
||||
# ── persist identity fields ────────────────────────────────────
|
||||
self.config_manager.set_identity_field('cell_name', cell_name)
|
||||
self.config_manager.set_identity_field('domain_mode', domain_mode)
|
||||
self.config_manager.set_identity_field('timezone', timezone)
|
||||
self.config_manager.set_identity_field('services_enabled', services_enabled)
|
||||
self.config_manager.set_identity_field('ddns_provider', ddns_provider)
|
||||
|
||||
# NOTE: DDNS registration is deferred to Phase 3.
|
||||
# For now we just store ddns_provider in config.
|
||||
logger.info(
|
||||
'DDNS registration skipped (Phase 1). '
|
||||
'DDNS registration will happen in Phase 3. '
|
||||
f'ddns_provider={ddns_provider!r} stored in identity config.'
|
||||
)
|
||||
|
||||
# ── mark setup complete (must be last) ─────────────────────────
|
||||
self.config_manager.set_identity_field('setup_complete', True)
|
||||
|
||||
logger.info(f"Setup completed. cell_name={cell_name!r}, domain_mode={domain_mode!r}")
|
||||
return {'success': True, 'redirect': '/login'}
|
||||
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user