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:
2026-05-09 08:05:38 -04:00
parent 6dbd0dff46
commit cf1b9672f4
12 changed files with 1754 additions and 16 deletions
+35 -12
View File
@@ -2,9 +2,9 @@
# Provides easy commands for managing the cell # Provides easy commands for managing the cell
.PHONY: help start stop restart status logs clean setup check-deps init-peers \ .PHONY: help start stop restart status logs clean setup check-deps init-peers \
update reinstall uninstall \ update reinstall uninstall install \
build build-api build-webui \ build build-api build-webui \
start-dns start-api start-wg start-webui \ start-core start-dns start-api start-wg start-webui \
backup restore \ backup restore \
test test-all test-unit test-coverage test-api test-cli \ test test-all test-unit test-coverage test-api test-cli \
test-phase1 test-phase2 test-phase3 test-phase4 test-all-phases \ test-phase1 test-phase2 test-phase3 test-phase4 test-all-phases \
@@ -93,12 +93,12 @@ init-peers:
start: start:
@echo "Starting Personal Internet Cell..." @echo "Starting Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) up -d --build PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
@echo "Services started. Check status with 'make status'" @echo "Services started. Check status with 'make status'"
stop: stop:
@echo "Stopping Personal Internet Cell..." @echo "Stopping Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) down PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down
@echo "Services stopped." @echo "Services stopped."
restart: restart:
@@ -135,17 +135,31 @@ update:
$(MAKE) setup; \ $(MAKE) setup; \
fi fi
@echo "Rebuilding and restarting services..." @echo "Rebuilding and restarting services..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) up -d --build PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build
@echo "Update complete. Run 'make status' to verify." @echo "Update complete. Run 'make status' to verify."
reinstall: reinstall:
@echo "Reinstalling Personal Internet Cell from scratch..." @echo "Reinstalling Personal Internet Cell from scratch..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) down -v 2>/dev/null || true PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v 2>/dev/null || true
@sudo rm -rf config/ data/ @sudo rm -rf config/ data/
@$(MAKE) setup @$(MAKE) setup
@$(MAKE) start @$(MAKE) start
@echo "Reinstall complete." @echo "Reinstall complete."
install:
@if [ -f /opt/pic/.installed ] && [ "$(FORCE)" != "1" ]; then \
echo "Already installed. Run 'make update' to update, or 'make install FORCE=1' to reinstall."; \
exit 0; \
fi
@echo "Running setup..."
@$(MAKE) setup
@echo "Installing systemd unit..."
@sudo cp scripts/pic.service /etc/systemd/system/pic.service
@-sudo systemctl daemon-reload && sudo systemctl enable pic
@sudo mkdir -p /opt/pic
@sudo touch /opt/pic/.installed
@echo "Installation complete. Run 'make start-core' to start core services."
uninstall: uninstall:
@echo "" @echo ""
@echo "This will stop and remove all containers." @echo "This will stop and remove all containers."
@@ -155,20 +169,24 @@ uninstall:
case "$$ans" in \ case "$$ans" in \
y|Y) \ y|Y) \
echo "Stopping containers and removing images..."; \ echo "Stopping containers and removing images..."; \
PUID=$$(id -u) PGID=$$(id -g) $(DC) down -v --rmi all 2>/dev/null || true; \ PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v --rmi all 2>/dev/null || true; \
echo "Deleting config/ and data/..."; \ echo "Deleting config/ and data/..."; \
sudo rm -rf config/ data/; \ sudo rm -rf config/ data/; \
echo "Uninstall complete. Git repo and scripts remain."; \ echo "Uninstall complete. Git repo and scripts remain."; \
;; \ ;; \
n|N|"") \ n|N|"") \
echo "Stopping and removing containers (keeping images and data)..."; \ echo "Stopping and removing containers (keeping images and data)..."; \
PUID=$$(id -u) PGID=$$(id -g) $(DC) down 2>/dev/null || true; \ PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down 2>/dev/null || true; \
echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \ echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \
;; \ ;; \
*) \ *) \
echo "Cancelled."; \ echo "Cancelled."; \
;; \ ;; \
esac esac
@-sudo systemctl disable pic 2>/dev/null || true
@-sudo rm -f /etc/systemd/system/pic.service
@-sudo rm -f /opt/pic/.installed
@echo "Note: Data volumes were not deleted. To remove all data, manually delete config/ and data/."
# ── Build ───────────────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────────────
@@ -188,17 +206,22 @@ build-webui:
# ── Individual services ─────────────────────────────────────────────────────── # ── Individual services ───────────────────────────────────────────────────────
start-core:
@echo "Starting core services (caddy, dns, wireguard, api, webui)..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile core up -d --build
@echo "Core services started. Run 'make start' to also bring up optional services."
start-dns: start-dns:
$(DC) up -d dns $(DC) --profile core up -d dns
start-api: start-api:
$(DC) up -d api $(DC) --profile core up -d api
start-wg: start-wg:
$(DC) up -d wireguard $(DC) --profile core up -d wireguard
start-webui: start-webui:
$(DC) up -d webui $(DC) --profile core up -d webui
# ── Maintenance ─────────────────────────────────────────────────────────────── # ── Maintenance ───────────────────────────────────────────────────────────────
+28 -2
View File
@@ -40,7 +40,7 @@ from managers import (
network_manager, wireguard_manager, peer_registry, network_manager, wireguard_manager, peer_registry,
email_manager, calendar_manager, file_manager, email_manager, calendar_manager, file_manager,
routing_manager, vault_manager, container_manager, routing_manager, vault_manager, container_manager,
cell_link_manager, auth_manager, cell_link_manager, auth_manager, setup_manager,
firewall_manager, EventType, firewall_manager, EventType,
) )
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` # 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 '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 @app.before_request
def enforce_auth(): def enforce_auth():
"""Enforce session-based authentication and role-based access control. """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'): if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
return None return None
path = request.path 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 return None
# peer-sync uses IP+pubkey auth — no session, no CSRF token possible # peer-sync uses IP+pubkey auth — no session, no CSRF token possible
if path.startswith('/api/cells/peer-sync/'): if path.startswith('/api/cells/peer-sync/'):
@@ -409,6 +431,10 @@ service_bus.register_service('container', container_manager)
# Register auth blueprint # Register auth blueprint
app.register_blueprint(auth_routes.auth_bp) 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) # Register service blueprints (routes extracted from this file)
from routes.email import bp as _email_bp from routes.email import bp as _email_bp
from routes.calendar import bp as _calendar_bp from routes.calendar import bp as _calendar_bp
+14
View File
@@ -37,6 +37,9 @@ class ConfigManager:
pass pass
self.service_schemas = self._load_service_schemas() self.service_schemas = self._load_service_schemas()
self.configs = self._load_all_configs() 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(): if not self.config_file.exists():
self._save_all_configs() self._save_all_configs()
@@ -460,6 +463,17 @@ class ConfigManager:
# No-op for unified config, but keep for compatibility # No-op for unified config, but keep for compatibility
pass 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]: def get_all_configs(self) -> Dict[str, Dict]:
"""Get all service configurations""" """Get all service configurations"""
return self.configs.copy() return self.configs.copy()
+3 -1
View File
@@ -27,6 +27,7 @@ from log_manager import LogManager
from cell_link_manager import CellLinkManager from cell_link_manager import CellLinkManager
import firewall_manager import firewall_manager
from auth_manager import AuthManager from auth_manager import AuthManager
from setup_manager import SetupManager
DATA_DIR = os.environ.get('DATA_DIR', '/app/data') DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
@@ -53,6 +54,7 @@ cell_link_manager = CellLinkManager(
network_manager=network_manager, network_manager=network_manager,
) )
auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR) 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 logger configuration
_service_log_configs = { _service_log_configs = {
@@ -86,7 +88,7 @@ __all__ = [
'network_manager', 'wireguard_manager', 'peer_registry', 'network_manager', 'wireguard_manager', 'peer_registry',
'email_manager', 'calendar_manager', 'file_manager', 'email_manager', 'calendar_manager', 'file_manager',
'routing_manager', 'vault_manager', 'container_manager', 'routing_manager', 'vault_manager', 'container_manager',
'cell_link_manager', 'auth_manager', 'cell_link_manager', 'auth_manager', 'setup_manager',
'firewall_manager', 'EventType', 'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR', 'DATA_DIR', 'CONFIG_DIR',
] ]
+58
View File
@@ -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
+206
View File
@@ -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 231 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
+12
View File
@@ -5,6 +5,7 @@ services:
caddy: caddy:
image: caddy:2-alpine image: caddy:2-alpine
container_name: cell-caddy container_name: cell-caddy
profiles: ["core", "full"]
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -28,6 +29,7 @@ services:
dns: dns:
image: coredns/coredns:latest image: coredns/coredns:latest
container_name: cell-dns container_name: cell-dns
profiles: ["core", "full"]
command: ["-conf", "/etc/coredns/Corefile"] command: ["-conf", "/etc/coredns/Corefile"]
ports: ports:
- "${DNS_PORT:-53}:53/udp" - "${DNS_PORT:-53}:53/udp"
@@ -49,6 +51,7 @@ services:
dhcp: dhcp:
image: alpine:latest image: alpine:latest
container_name: cell-dhcp container_name: cell-dhcp
profiles: ["full"]
ports: ports:
- "${DHCP_PORT:-67}:67/udp" - "${DHCP_PORT:-67}:67/udp"
volumes: volumes:
@@ -71,6 +74,7 @@ services:
ntp: ntp:
image: alpine:latest image: alpine:latest
container_name: cell-ntp container_name: cell-ntp
profiles: ["full"]
ports: ports:
- "${NTP_PORT:-123}:123/udp" - "${NTP_PORT:-123}:123/udp"
volumes: volumes:
@@ -92,6 +96,7 @@ services:
mail: mail:
image: mailserver/docker-mailserver:latest image: mailserver/docker-mailserver:latest
container_name: cell-mail container_name: cell-mail
profiles: ["full"]
hostname: mail hostname: mail
domainname: cell.local domainname: cell.local
env_file: ./config/mail/mailserver.env env_file: ./config/mail/mailserver.env
@@ -121,6 +126,7 @@ services:
radicale: radicale:
image: tomsquest/docker-radicale:latest image: tomsquest/docker-radicale:latest
container_name: cell-radicale container_name: cell-radicale
profiles: ["full"]
ports: ports:
- "127.0.0.1:${RADICALE_PORT:-5232}:5232" - "127.0.0.1:${RADICALE_PORT:-5232}:5232"
volumes: volumes:
@@ -140,6 +146,7 @@ services:
webdav: webdav:
image: bytemark/webdav:latest image: bytemark/webdav:latest
container_name: cell-webdav container_name: cell-webdav
profiles: ["full"]
ports: ports:
- "127.0.0.1:${WEBDAV_PORT:-8080}:80" - "127.0.0.1:${WEBDAV_PORT:-8080}:80"
environment: environment:
@@ -162,6 +169,7 @@ services:
wireguard: wireguard:
image: linuxserver/wireguard:latest image: linuxserver/wireguard:latest
container_name: cell-wireguard container_name: cell-wireguard
profiles: ["core", "full"]
environment: environment:
- SERVERMODE=true - SERVERMODE=true
- PUID=${PUID:-1000} - PUID=${PUID:-1000}
@@ -193,6 +201,7 @@ services:
api: api:
build: ./api build: ./api
container_name: cell-api container_name: cell-api
profiles: ["core", "full"]
ports: ports:
- "127.0.0.1:${API_PORT:-3000}:3000" - "127.0.0.1:${API_PORT:-3000}:3000"
volumes: volumes:
@@ -225,6 +234,7 @@ services:
webui: webui:
build: ./webui build: ./webui
container_name: cell-webui container_name: cell-webui
profiles: ["core", "full"]
ports: ports:
- "${WEBUI_PORT:-8081}:80" - "${WEBUI_PORT:-8081}:80"
restart: unless-stopped restart: unless-stopped
@@ -241,6 +251,7 @@ services:
rainloop: rainloop:
image: hardware/rainloop image: hardware/rainloop
container_name: cell-rainloop container_name: cell-rainloop
profiles: ["full"]
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
@@ -259,6 +270,7 @@ services:
filegator: filegator:
image: filegator/filegator image: filegator/filegator
container_name: cell-filegator container_name: cell-filegator
profiles: ["full"]
restart: unless-stopped restart: unless-stopped
networks: networks:
cell-network: cell-network:
Executable
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env bash
# =============================================================================
# Personal Internet Cell (PIC) — Bash Installer
# =============================================================================
#
# SECURITY NOTICE
# ---------------
# You are about to execute this script with elevated privileges.
# ALWAYS review a script before running it:
#
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh | less
#
# SHA256 checksum (verify before running):
# PLACEHOLDER — updated when script is published at git.pic.ngo
#
# Verify with:
# sha256sum install.sh
# # or, via curl before piping:
# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh \
# | sha256sum
#
# =============================================================================
#
# Usage:
# sudo bash install.sh # Standard install
# sudo bash install.sh --force # Bypass idempotency check
# sudo PIC_DIR=/srv/pic bash install.sh # Custom install directory
#
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
#
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PIC_DIR="${PIC_DIR:-/opt/pic}"
PIC_REPO="${PIC_REPO:-https://git.pic.ngo/roof/pic.git}"
PIC_USER="${PIC_USER:-pic}"
API_HEALTH_URL="http://127.0.0.1:3000/health"
API_HEALTH_TIMEOUT=60
WEBUI_PORT=8081
FORCE=0
# Parse flags
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
*)
echo "Unknown argument: $arg" >&2
echo "Usage: $0 [--force]" >&2
exit 1
;;
esac
done
# ---------------------------------------------------------------------------
# Color output
# ---------------------------------------------------------------------------
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && tput setaf 1 >/dev/null 2>&1; then
RED="$(tput setaf 1)"
GREEN="$(tput setaf 2)"
YELLOW="$(tput setaf 3)"
BOLD="$(tput bold)"
RESET="$(tput sgr0)"
else
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'
fi
log_step() { printf "\n${BOLD}[%s/%s] %s${RESET}\n" "$1" "$TOTAL_STEPS" "$2"; }
log_ok() { printf " ${GREEN}${RESET} %s\n" "$1"; }
log_warn() { printf " ${YELLOW}${RESET} %s\n" "$1"; }
log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >&2; }
die() { log_error "$1"; exit 1; }
TOTAL_STEPS=7
# ---------------------------------------------------------------------------
# Must run as root
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
die "This installer must be run as root (use sudo)."
fi
# ---------------------------------------------------------------------------
# Idempotency guard
# ---------------------------------------------------------------------------
if [ -f "${PIC_DIR}/.installed" ] && [ "$FORCE" -eq 0 ]; then
printf "${YELLOW}Already installed.${RESET} Run ${BOLD}'make update'${RESET} to update.\n"
printf "To force a full reinstall, run: ${BOLD}$0 --force${RESET}\n"
exit 0
fi
# ---------------------------------------------------------------------------
# Step 1 — Detect OS / package manager
# ---------------------------------------------------------------------------
log_step 1 "Detecting operating system..."
PKG_MANAGER=""
OS_ID=""
if [ -f /etc/os-release ]; then
# shellcheck source=/dev/null
. /etc/os-release
OS_ID="${ID:-unknown}"
fi
case "$OS_ID" in
ubuntu|debian|raspbian)
PKG_MANAGER="apt"
;;
fedora)
PKG_MANAGER="dnf"
;;
rhel|centos|almalinux|rocky)
PKG_MANAGER="dnf"
;;
alpine)
PKG_MANAGER="apk"
;;
*)
# Last-resort detection
if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
elif command -v dnf >/dev/null 2>&1; then
PKG_MANAGER="dnf"
elif command -v apk >/dev/null 2>&1; then
PKG_MANAGER="apk"
else
die "Unsupported OS '${OS_ID}'. PIC requires Debian/Ubuntu, Fedora/RHEL, or Alpine Linux."
fi
;;
esac
log_ok "Detected OS: ${OS_ID} (package manager: ${PKG_MANAGER})"
# ---------------------------------------------------------------------------
# Step 2 — Install required packages
# ---------------------------------------------------------------------------
log_step 2 "Installing dependencies..."
case "$PKG_MANAGER" in
apt)
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
| grep -v "^$" | sed 's/^/ /' || true
# Verify docker compose plugin installed
if ! docker compose version >/dev/null 2>&1; then
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
fi
;;
dnf)
dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
# Enable and start Docker (dnf installs but doesn't enable it)
systemctl enable --now docker >/dev/null 2>&1 || true
# Docker Compose plugin comes bundled with the Docker CE package on Fedora/RHEL.
# If not present, install via the docker-compose-plugin package (Docker CE repo).
if ! docker compose version >/dev/null 2>&1; then
log_warn "docker compose plugin not found; installing docker-compose-plugin..."
dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
fi
;;
apk)
apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
# Enable Docker on Alpine (OpenRC)
rc-update add docker default >/dev/null 2>&1 || true
service docker start >/dev/null 2>&1 || true
;;
esac
# Final sanity checks
command -v git >/dev/null 2>&1 || die "git could not be installed. Aborting."
command -v curl >/dev/null 2>&1 || die "curl could not be installed. Aborting."
command -v make >/dev/null 2>&1 || die "make could not be installed. Aborting."
command -v docker >/dev/null 2>&1 || die "docker could not be installed. Aborting."
docker compose version >/dev/null 2>&1 || \
docker-compose version >/dev/null 2>&1 || \
die "Neither 'docker compose' (plugin) nor 'docker-compose' is available. Aborting."
log_ok "All dependencies satisfied"
# ---------------------------------------------------------------------------
# Step 3 — Create system user
# ---------------------------------------------------------------------------
log_step 3 "Configuring system user..."
if ! id "$PIC_USER" >/dev/null 2>&1; then
case "$PKG_MANAGER" in
apk)
adduser -S -D -H -s /sbin/nologin "$PIC_USER"
;;
*)
useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
;;
esac
log_ok "Created system user: ${PIC_USER}"
else
log_ok "System user already exists: ${PIC_USER}"
fi
# Ensure docker group exists and user is in it
if ! getent group docker >/dev/null 2>&1; then
groupadd docker
log_ok "Created docker group"
fi
if ! id -nG "$PIC_USER" | grep -qw docker; then
usermod -aG docker "$PIC_USER"
log_ok "Added ${PIC_USER} to docker group"
else
log_ok "${PIC_USER} is already in docker group"
fi
# ---------------------------------------------------------------------------
# Step 4 — Clone or update repository
# ---------------------------------------------------------------------------
log_step 4 "Setting up repository at ${PIC_DIR}..."
if [ -d "${PIC_DIR}/.git" ]; then
log_warn "Repository already cloned — running git pull"
git -C "$PIC_DIR" pull --ff-only 2>&1 | sed 's/^/ /'
log_ok "Repository updated"
elif [ -d "$PIC_DIR" ] && [ "$(ls -A "$PIC_DIR" 2>/dev/null)" ]; then
die "${PIC_DIR} exists and is not empty and is not a git repo. Aborting to avoid data loss."
else
mkdir -p "$(dirname "$PIC_DIR")"
git clone "$PIC_REPO" "$PIC_DIR" 2>&1 | sed 's/^/ /'
log_ok "Repository cloned to ${PIC_DIR}"
fi
# Ensure the pic user owns the directory
chown -R "${PIC_USER}:${PIC_USER}" "$PIC_DIR"
# ---------------------------------------------------------------------------
# Step 5 — Run make install
# ---------------------------------------------------------------------------
log_step 5 "Running 'make install'..."
# make install generates config, writes the systemd unit, and touches .installed.
# We run it as the pic user (via sudo -u) so files get correct ownership, but
# make install itself calls sudo internally where root is needed.
cd "$PIC_DIR"
if ! make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above."
fi
log_ok "'make install' complete"
# ---------------------------------------------------------------------------
# Step 6 — Start core services
# ---------------------------------------------------------------------------
log_step 6 "Starting core services..."
cd "$PIC_DIR"
if ! make start-core 2>&1 | sed 's/^/ /'; then
die "'make start-core' failed. Check the output above."
fi
log_ok "Core services started"
# ---------------------------------------------------------------------------
# Step 7 — Health check + print wizard URL
# ---------------------------------------------------------------------------
log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
ELAPSED=0
HEALTHY=0
while [ "$ELAPSED" -lt "$API_HEALTH_TIMEOUT" ]; do
if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=1
break
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
printf " Waiting... (%ds)\r" "$ELAPSED"
done
printf "\n"
if [ "$HEALTHY" -ne 1 ]; then
log_warn "API did not respond within ${API_HEALTH_TIMEOUT}s at ${API_HEALTH_URL}"
log_warn "The stack may still be starting up. Check with: make -C ${PIC_DIR} status"
log_warn "Or follow logs with: make -C ${PIC_DIR} logs"
else
log_ok "API is healthy"
fi
# Detect the host's primary outbound IP address
HOST_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{print $7; exit}' || true)"
if [ -z "$HOST_IP" ]; then
# Fallback: first non-loopback IPv4
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
fi
HOST_IP="${HOST_IP:-<host-ip>}"
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
printf "\n${GREEN}${BOLD}============================================================${RESET}\n"
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
printf "${GREEN}${BOLD}============================================================${RESET}\n"
printf "\n"
printf " Open the setup wizard at:\n"
printf "\n"
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
printf "\n"
printf " Useful commands:\n"
printf " make -C ${PIC_DIR} status — check service status\n"
printf " make -C ${PIC_DIR} logs — follow all service logs\n"
printf " make -C ${PIC_DIR} start — start all services\n"
printf " make -C ${PIC_DIR} stop — stop all services\n"
printf " make -C ${PIC_DIR} update — pull latest code and restart\n"
printf "\n"
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Personal Internet Cell
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/pic
ExecStart=/usr/bin/make start
ExecStop=/usr/bin/make stop
TimeoutStartSec=120
[Install]
WantedBy=multi-user.target
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""
Unit tests for SetupManager (api/setup_manager.py).
Config manager and auth manager are injected as MagicMock objects so no
filesystem access or Docker calls are needed.
"""
import os
import sys
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from setup_manager import SetupManager, AVAILABLE_SERVICES, AVAILABLE_TIMEZONES
# ── fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def mock_config_manager():
"""A MagicMock standing in for ConfigManager."""
mgr = MagicMock()
# Default: setup not yet complete
mgr.get_identity.return_value = {}
return mgr
@pytest.fixture
def mock_auth_manager():
"""A MagicMock standing in for AuthManager."""
mgr = MagicMock()
mgr.create_user.return_value = True
return mgr
@pytest.fixture
def setup_manager(mock_config_manager, mock_auth_manager):
"""SetupManager wired to both mocks."""
return SetupManager(mock_config_manager, mock_auth_manager)
# ── valid payload helper ───────────────────────────────────────────────────────
def _valid_payload(**overrides):
base = {
'cell_name': 'mycel',
'password': 'SecurePass1!',
'domain_mode': 'lan',
'timezone': 'UTC',
'services_enabled': ['wireguard'],
'ddns_provider': 'none',
}
base.update(overrides)
return base
# ── is_setup_complete ─────────────────────────────────────────────────────────
def test_is_setup_complete_missing_key_returns_false(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
assert setup_manager.is_setup_complete() is False
def test_is_setup_complete_false_value_returns_false(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': False}
assert setup_manager.is_setup_complete() is False
def test_is_setup_complete_true_value_returns_true(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': True}
assert setup_manager.is_setup_complete() is True
# ── validate_cell_name ────────────────────────────────────────────────────────
@pytest.mark.parametrize('name', ['mycel', 'my-cel', 'a1', 'abc-123-xyz'])
def test_validate_cell_name_accepts_valid_names(setup_manager, name):
assert setup_manager.validate_cell_name(name) == []
def test_validate_cell_name_rejects_empty_string(setup_manager):
errors = setup_manager.validate_cell_name('')
assert errors
assert any('required' in e.lower() for e in errors)
def test_validate_cell_name_rejects_starts_with_digit(setup_manager):
errors = setup_manager.validate_cell_name('1abc')
assert errors
def test_validate_cell_name_rejects_starts_with_hyphen(setup_manager):
errors = setup_manager.validate_cell_name('-abc')
assert errors
def test_validate_cell_name_rejects_ends_with_hyphen(setup_manager):
errors = setup_manager.validate_cell_name('abc-')
assert errors
def test_validate_cell_name_rejects_uppercase(setup_manager):
errors = setup_manager.validate_cell_name('MyCell')
assert errors
def test_validate_cell_name_rejects_underscore(setup_manager):
errors = setup_manager.validate_cell_name('my_cell')
assert errors
def test_validate_cell_name_rejects_dot(setup_manager):
errors = setup_manager.validate_cell_name('my.cell')
assert errors
def test_validate_cell_name_rejects_too_short_single_char(setup_manager):
# Single character: regex requires at least 2 chars (start + 1-30 more)
errors = setup_manager.validate_cell_name('a')
assert errors
def test_validate_cell_name_rejects_too_long(setup_manager):
# 32 lowercase letters — one over the 31-char limit
errors = setup_manager.validate_cell_name('a' * 32)
assert errors
def test_validate_cell_name_accepts_maximum_length(setup_manager):
# 31 chars: 'a' + 30 more lowercase = exactly at limit
assert setup_manager.validate_cell_name('a' + 'b' * 30) == []
# ── validate_password ─────────────────────────────────────────────────────────
def test_validate_password_accepts_valid_password(setup_manager):
assert setup_manager.validate_password('SecurePass1!') == []
def test_validate_password_rejects_too_short(setup_manager):
errors = setup_manager.validate_password('Short1!')
assert errors
assert any('12' in e or 'least' in e.lower() for e in errors)
def test_validate_password_rejects_no_uppercase(setup_manager):
errors = setup_manager.validate_password('securepass1!')
assert errors
assert any('uppercase' in e.lower() for e in errors)
def test_validate_password_rejects_no_lowercase(setup_manager):
errors = setup_manager.validate_password('SECUREPASS1!')
assert errors
assert any('lowercase' in e.lower() for e in errors)
def test_validate_password_rejects_no_digit(setup_manager):
errors = setup_manager.validate_password('SecurePassword!')
assert errors
assert any('digit' in e.lower() for e in errors)
# ── complete_setup ────────────────────────────────────────────────────────────
def test_complete_setup_returns_error_when_cell_name_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(cell_name='1bad'))
assert result['success'] is False
assert result['errors']
def test_complete_setup_returns_error_when_password_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(password='weak'))
assert result['success'] is False
assert result['errors']
def test_complete_setup_returns_error_when_domain_mode_invalid(setup_manager):
result = setup_manager.complete_setup(_valid_payload(domain_mode='ftp'))
assert result['success'] is False
assert any('domain_mode' in e for e in result['errors'])
def test_complete_setup_calls_create_user_with_correct_args(
setup_manager, mock_auth_manager, mock_config_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
mock_auth_manager.create_user.assert_called_once_with(
username='admin',
password='SecurePass1!',
role='admin',
)
def test_complete_setup_calls_set_identity_field_for_each_field(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
calls = mock_config_manager.set_identity_field.call_args_list
field_names = [c[0][0] for c in calls]
for expected in ('cell_name', 'domain_mode', 'timezone', 'services_enabled', 'ddns_provider'):
assert expected in field_names, f"set_identity_field not called for '{expected}'"
def test_complete_setup_marks_setup_complete_last(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
"""setup_complete must be the final set_identity_field call."""
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
calls = mock_config_manager.set_identity_field.call_args_list
last_call = calls[-1]
assert last_call == call('setup_complete', True)
def test_complete_setup_returns_success_redirect_on_valid_payload(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result == {'success': True, 'redirect': '/login'}
def test_complete_setup_returns_error_when_already_complete(
setup_manager, mock_config_manager, tmp_path):
"""If setup is already done when the lock-protected re-check runs, return error."""
# complete_setup calls is_setup_complete() exactly once — inside the lock.
# Returning True there triggers the "already completed" guard.
mock_config_manager.get_identity.return_value = {'setup_complete': True}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False
assert any('already' in e.lower() for e in result['errors'])
def test_complete_setup_does_not_persist_fields_when_already_complete(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
"""No side-effects (no create_user, no set_identity_field) when already done."""
mock_config_manager.get_identity.return_value = {'setup_complete': True}
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
setup_manager.complete_setup(_valid_payload())
mock_auth_manager.create_user.assert_not_called()
mock_config_manager.set_identity_field.assert_not_called()
def test_complete_setup_returns_error_when_create_user_fails(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {}
mock_auth_manager.create_user.return_value = False
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False
assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors'])
# ── get_setup_status ──────────────────────────────────────────────────────────
def test_get_setup_status_returns_complete_key(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'complete' in status
assert status['complete'] is False
def test_get_setup_status_complete_reflects_true_when_done(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {'setup_complete': True}
status = setup_manager.get_setup_status()
assert status['complete'] is True
def test_get_setup_status_contains_available_services(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'available_services' in status
assert isinstance(status['available_services'], list)
assert status['available_services'] == AVAILABLE_SERVICES
def test_get_setup_status_contains_available_timezones(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'available_timezones' in status
assert isinstance(status['available_timezones'], list)
assert len(status['available_timezones']) > 0
def test_get_setup_status_timezones_includes_utc(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert 'UTC' in status['available_timezones']
def test_get_setup_status_timezones_match_module_constant(setup_manager, mock_config_manager):
mock_config_manager.get_identity.return_value = {}
status = setup_manager.get_setup_status()
assert status['available_timezones'] == AVAILABLE_TIMEZONES
+43
View File
@@ -0,0 +1,43 @@
import React, { useState, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { setupAPI } from '../services/api';
export default function SetupGuard({ children }) {
const location = useLocation();
const [status, setStatus] = useState(null); // null = loading, true = complete, false = incomplete
const [error, setError] = useState(false);
useEffect(() => {
setupAPI.getStatus()
.then(r => setStatus(r.data?.complete === true))
.catch(() => {
// If the setup endpoint doesn't exist yet, treat setup as complete
// so the rest of the app functions normally.
setStatus(true);
setError(true);
});
}, []);
// Still loading show nothing to avoid flash of wrong content
if (status === null) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
);
}
const onSetupPage = location.pathname === '/setup';
// Setup incomplete and not already on /setup redirect there
if (status === false && !onSetupPage) {
return <Navigate to="/setup" replace />;
}
// Setup complete but user navigated to /setup send to login
if (status === true && onSetupPage) {
return <Navigate to="/login" replace />;
}
return children;
}
+707
View File
@@ -0,0 +1,707 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import { setupAPI } from '../services/api';
// constants
const TOTAL_STEPS = 7;
const CELL_NAME_RE = /^[a-z][a-z0-9-]{1,30}$/;
const DOMAIN_OPTIONS = [
{
value: 'pic_ngo',
label: 'PIC.NGO subdomain',
description: 'Get a free yourname.pic.ngo domain — managed automatically.',
},
{
value: 'custom',
label: 'Custom domain',
description: 'Bring your own domain. You will configure DNS records manually.',
},
{
value: 'lan_only',
label: 'LAN only',
description: 'No public domain. Accessible only on your local network and via VPN.',
},
];
const DDNS_OPTIONS = [
{ value: 'pic_ngo', label: 'pic.ngo (managed)', description: 'Automatic — no setup required.' },
{ value: 'cloudflare', label: 'Cloudflare', description: 'Use Cloudflare DNS with API token.' },
{ value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' },
{ value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' },
{ value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' },
{ value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' },
];
const OPTIONAL_SERVICES = [
{ key: 'email', label: 'Email', description: 'Postfix + Dovecot IMAP/SMTP server.' },
{ key: 'calendar', label: 'Calendar & Contacts', description: 'CalDAV/CardDAV via Radicale.' },
{ key: 'files', label: 'Files (WebDAV)', description: 'WebDAV file storage accessible from any device.' },
{ key: 'webmail', label: 'Webmail UI', description: 'Browser-based email client (Roundcube).' },
];
const ALWAYS_ON_SERVICES = [
{ key: 'vpn', label: 'VPN (WireGuard)' },
{ key: 'dns', label: 'DNS (CoreDNS)' },
{ key: 'api', label: 'API (cell-api)' },
];
// helpers
function getAllTimezones() {
try {
return Intl.supportedValuesOf('timeZone');
} catch {
// Fallback list for older browsers
return [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Australia/Sydney',
];
}
}
function passwordStrength(pw) {
if (!pw) return { label: '', color: '', width: '0%' };
let score = 0;
if (pw.length >= 12) score++;
if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
if (score <= 1) return { label: 'Weak', color: 'bg-red-500', width: '20%' };
if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' };
if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' };
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
}
// sub-components
function StepHeader({ step, title, description }) {
return (
<div className="mb-6">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wider mb-1">
Step {step} of {TOTAL_STEPS}
</p>
<h2 className="text-lg font-semibold text-white">{title}</h2>
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
</div>
);
}
function ProgressBar({ step }) {
const pct = Math.round((step / TOTAL_STEPS) * 100);
return (
<div className="mb-8">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>Setup progress</span>
<span>{pct}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
role="progressbar"
aria-valuenow={step}
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
/>
</div>
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-400" role="alert">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
{message}
</p>
);
}
function RadioOption({ value, selected, label, description, onChange }) {
return (
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selected
? 'border-blue-500 bg-blue-950/40'
: 'border-gray-700 hover:border-gray-500'
}`}
>
<input
type="radio"
className="mt-0.5 accent-blue-500"
value={value}
checked={selected}
onChange={() => onChange(value)}
/>
<div>
<div className="text-sm font-medium text-white">{label}</div>
{description && <div className="text-xs text-gray-400 mt-0.5">{description}</div>}
</div>
</label>
);
}
function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false, loading = false }) {
return (
<div className="flex justify-between mt-8 pt-6 border-t border-gray-700">
{onBack ? (
<button
type="button"
onClick={onBack}
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-md transition-colors"
>
Back
</button>
) : (
<div />
)}
<button
type="button"
onClick={onNext}
disabled={nextDisabled || loading}
className="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2"
>
{loading && (
<span className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white" />
)}
{nextLabel}
</button>
</div>
);
}
// step screens
function Step1CellName({ value, onChange, onNext }) {
const [error, setError] = useState('');
const [serverError, setServerError] = useState('');
const [loading, setLoading] = useState(false);
const validate = () => {
if (!value.trim()) return 'Cell name is required.';
if (!CELL_NAME_RE.test(value))
return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 231 characters.';
return '';
};
const handleNext = async () => {
const err = validate();
setError(err);
setServerError('');
if (err) return;
setLoading(true);
try {
await setupAPI.validate('cell_name', { cell_name: value });
onNext();
} catch (e) {
setServerError(
e?.response?.data?.error || 'Validation failed. Please try a different name.'
);
} finally {
setLoading(false);
}
};
return (
<div>
<StepHeader
step={1}
title="Name your cell"
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs."
/>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
Cell name <span className="text-red-400">*</span>
</label>
<input
id="cell-name"
type="text"
autoComplete="off"
spellCheck={false}
value={value}
onChange={e => {
onChange(e.target.value.toLowerCase());
setError('');
setServerError('');
}}
onKeyDown={e => e.key === 'Enter' && handleNext()}
placeholder="e.g. homelab"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
/>
<p className="mt-1 text-xs text-gray-500">
Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters.
</p>
<div id="cell-name-error">
<FieldError message={error || serverError} />
</div>
</div>
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
</div>
);
}
function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, onNext, onBack }) {
const [showPw, setShowPw] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errors, setErrors] = useState({});
const strength = passwordStrength(password);
const validate = () => {
const e = {};
if (!password) e.password = 'Password is required.';
else if (password.length < 12) e.password = 'Password must be at least 12 characters.';
if (!confirm) e.confirm = 'Please confirm your password.';
else if (password !== confirm) e.confirm = 'Passwords do not match.';
return e;
};
const handleNext = () => {
const e = validate();
setErrors(e);
if (Object.keys(e).length === 0) onNext();
};
const isReady = password.length >= 12 && password === confirm;
return (
<div>
<StepHeader
step={2}
title="Set admin password"
description="This password protects access to your cell. Choose something strong and store it safely."
/>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw">
Password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
id="pw"
type={showPw ? 'text' : 'password'}
autoComplete="new-password"
value={password}
onChange={e => { onChangePassword(e.target.value); setErrors(p => ({ ...p, password: '' })); }}
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500"
aria-describedby={errors.password ? 'pw-error' : undefined}
/>
<button
type="button"
onClick={() => setShowPw(v => !v)}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
tabIndex={-1}
aria-label={showPw ? 'Hide password' : 'Show password'}
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* Strength bar */}
{password.length > 0 && (
<div className="mt-2">
<div className="w-full bg-gray-700 rounded-full h-1">
<div
className={`h-1 rounded-full transition-all duration-300 ${strength.color}`}
style={{ width: strength.width }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">Strength: {strength.label}</p>
</div>
)}
<div id="pw-error"><FieldError message={errors.password} /></div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw-confirm">
Confirm password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
id="pw-confirm"
type={showConfirm ? 'text' : 'password'}
autoComplete="new-password"
value={confirm}
onChange={e => { onChangeConfirm(e.target.value); setErrors(p => ({ ...p, confirm: '' })); }}
onKeyDown={e => e.key === 'Enter' && handleNext()}
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500"
aria-describedby={errors.confirm ? 'pw-confirm-error' : undefined}
/>
<button
type="button"
onClick={() => setShowConfirm(v => !v)}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
tabIndex={-1}
aria-label={showConfirm ? 'Hide password' : 'Show password'}
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
</div>
</div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
</div>
);
}
function Step3Domain({ value, onChange, onNext, onBack }) {
return (
<div>
<StepHeader
step={3}
title="Choose your domain"
description="How will you and your peers reach this cell over the internet?"
/>
<div className="space-y-2">
{DOMAIN_OPTIONS.map(opt => (
<RadioOption
key={opt.value}
value={opt.value}
label={opt.label}
description={opt.description}
selected={value === opt.value}
onChange={onChange}
/>
))}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
function Step4DDNS({ value, onChange, onNext, onBack }) {
return (
<div>
<StepHeader
step={4}
title="DDNS provider"
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup."
/>
<div className="space-y-2">
{DDNS_OPTIONS.map(opt => (
<RadioOption
key={opt.value}
value={opt.value}
label={opt.label}
description={opt.description}
selected={value === opt.value}
onChange={onChange}
/>
))}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
function Step5Services({ selected, onChange, onNext, onBack }) {
const toggle = key => {
onChange(
selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]
);
};
return (
<div>
<StepHeader
step={5}
title="Optional services"
description="Choose which services to enable. You can change this later in Settings."
/>
{/* Optional services */}
<div className="space-y-2 mb-6">
{OPTIONAL_SERVICES.map(svc => {
const checked = selected.includes(svc.key);
return (
<label
key={svc.key}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
checked ? 'border-blue-500 bg-blue-950/40' : 'border-gray-700 hover:border-gray-500'
}`}
>
<input
type="checkbox"
className="mt-0.5 accent-blue-500"
checked={checked}
onChange={() => toggle(svc.key)}
/>
<div>
<div className="text-sm font-medium text-white">{svc.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{svc.description}</div>
</div>
</label>
);
})}
</div>
{/* Always-on services */}
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Always enabled
</p>
<div className="space-y-1.5">
{ALWAYS_ON_SERVICES.map(svc => (
<div
key={svc.key}
className="flex items-center gap-3 p-3 rounded-lg border border-gray-800 bg-gray-900/40 opacity-60"
>
<input type="checkbox" checked readOnly disabled className="mt-0 accent-blue-500" aria-label={`${svc.label} is always enabled`} />
<span className="text-sm text-gray-400">{svc.label}</span>
<span className="ml-auto text-xs text-gray-600">always enabled</span>
</div>
))}
</div>
</div>
<NavButtons onBack={onBack} onNext={onNext} />
</div>
);
}
function Step6Timezone({ value, onChange, onNext, onBack }) {
const [query, setQuery] = useState('');
const allZones = useMemo(() => getAllTimezones(), []);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return q ? allZones.filter(z => z.toLowerCase().includes(q)) : allZones;
}, [query, allZones]);
return (
<div>
<StepHeader
step={6}
title="Timezone"
description="Used for log timestamps, cron jobs, and email headers."
/>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-search">
Search timezone
</label>
<input
id="tz-search"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="e.g. New York, Berlin, Tokyo"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600 mb-2"
/>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-select">
Select timezone <span className="text-red-400">*</span>
</label>
<select
id="tz-select"
value={value}
onChange={e => onChange(e.target.value)}
size={8}
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
>
{filtered.map(z => (
<option key={z} value={z}>{z}</option>
))}
</select>
{value && (
<p className="mt-2 text-xs text-gray-400">
Selected: <span className="text-white">{value}</span>
</p>
)}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
function ReviewRow({ label, value }) {
return (
<div className="flex justify-between py-2.5 border-b border-gray-800 last:border-0">
<span className="text-sm text-gray-400">{label}</span>
<span className="text-sm text-white font-medium text-right max-w-[60%] break-words">{value}</span>
</div>
);
}
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type;
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider;
const serviceLabels = fields.services.length
? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
: 'None selected';
return (
<div>
<StepHeader
step={7}
title="Review and finish"
description="Check your choices below. You can go back to change anything before completing setup."
/>
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
<ReviewRow label="Cell name" value={fields.cell_name} />
<ReviewRow label="Admin password" value="••••••••••••" />
<ReviewRow label="Domain type" value={domainLabel} />
{fields.domain_type !== 'lan_only' && (
<ReviewRow label="DDNS provider" value={ddnsLabel} />
)}
<ReviewRow label="Optional services" value={serviceLabels} />
<ReviewRow label="Timezone" value={fields.timezone} />
</div>
{submitError && (
<div className="mt-4 p-3 bg-red-950/50 border border-red-700 rounded-lg flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-300">{submitError}</p>
</div>
)}
<NavButtons
onBack={onBack}
onNext={onSubmit}
nextLabel="Complete setup"
loading={submitting}
/>
</div>
);
}
// main component
export default function Setup() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [done, setDone] = useState(false);
// Form state
const [cellName, setCellName] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [domainType, setDomainType] = useState('pic_ngo');
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo');
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
const [timezone, setTimezone] = useState(
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
);
// Submit state
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const skipDdns = domainType === 'lan_only';
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
const goBack = () => setStep(s => Math.max(s - 1, 1));
// Skip step 4 when LAN only
const handleStep3Next = () => {
if (skipDdns) setStep(5);
else setStep(4);
};
const handleStep4Back = () => setStep(3);
const handleStep5Back = () => {
if (skipDdns) setStep(3);
else setStep(4);
};
const handleSubmit = async () => {
setSubmitError('');
setSubmitting(true);
const payload = {
cell_name: cellName,
password,
domain_type: domainType,
...(skipDdns ? {} : { ddns_provider: ddnsProvider }),
services,
timezone,
};
try {
await setupAPI.complete(payload);
setDone(true);
setTimeout(() => navigate('/login', { replace: true }), 2000);
} catch (e) {
setSubmitError(
e?.response?.data?.error ||
'Setup could not be completed. Please check your entries and try again.'
);
} finally {
setSubmitting(false);
}
};
const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone };
if (done) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-950">
<div className="text-center">
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-white mb-2">Setup complete!</h2>
<p className="text-sm text-gray-400">Redirecting to login...</p>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
{/* Page title */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
</div>
<ProgressBar step={step} />
{step === 1 && (
<Step1CellName value={cellName} onChange={setCellName} onNext={goNext} />
)}
{step === 2 && (
<Step2Password
password={password}
confirm={passwordConfirm}
onChangePassword={setPassword}
onChangeConfirm={setPasswordConfirm}
onNext={goNext}
onBack={goBack}
/>
)}
{step === 3 && (
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
)}
{step === 4 && (
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} />
)}
{step === 5 && (
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
)}
{step === 6 && (
<Step6Timezone value={timezone} onChange={setTimezone} onNext={goNext} onBack={goBack} />
)}
{step === 7 && (
<Step7Review
fields={allFields}
onBack={goBack}
onSubmit={handleSubmit}
submitting={submitting}
submitError={submitError}
/>
)}
</div>
</div>
);
}