# Personal Internet Cell – Project Wiki ## Overview Personal Internet Cell (PIC) is a self-hosted digital infrastructure platform. It runs DNS, NTP, WireGuard VPN, HTTPS reverse proxy, a certificate authority, and optional services (email, calendar/contacts, file storage, connectivity exits, and more) — all managed from a single REST API and React web UI. The goal is to give a person full ownership of their core internet services on their own hardware, without relying on cloud providers. --- ## Table of Contents 1. [Architecture](#architecture) 2. [Service Managers](#service-managers) 3. [First-Run Wizard](#first-run-wizard) 4. [Authentication](#authentication) 5. [API Reference](#api-reference) 6. [DDNS](#ddns) 7. [Network Services and DNS](#network-services-and-dns) 8. [Services UI](#services-ui) 9. [Service Store (Add-ons)](#service-store-add-ons) 10. [Backup and Restore](#backup-and-restore) 11. [Cell-to-Cell Networking](#cell-to-cell-networking) 12. [Extended Connectivity](#extended-connectivity) 13. [Security Model](#security-model) 14. [Testing](#testing) 15. [Development](#development) --- ## Architecture ``` Browser / WireGuard peer └── Caddy (:80/:443) reverse proxy, TLS termination └── React SPA (:8081→8080) Vite + Tailwind (Nginx in container) └── Flask API (:3000) REST API, bound to 127.0.0.1 ├── NetworkManager CoreDNS, chrony (split-horizon DNS) ├── WireGuardManager WireGuard VPN peer lifecycle ├── PeerRegistry peer registration and trust ├── EmailManager Postfix + Dovecot (optional service) ├── CalendarManager Radicale CalDAV/CardDAV (optional service) ├── FileManager WebDAV + Filegator (optional service) ├── RoutingManager iptables NAT and routing ├── FirewallManager iptables firewall rules ├── VaultManager internal CA, cert lifecycle, Fernet encryption ├── ContainerManager Docker SDK ├── CellLinkManager cell-to-cell WireGuard links ├── ConnectivityManager exit routing (WG ext, OpenVPN, Tor, sshuttle, proxy) ├── DDNSManager dynamic DNS heartbeat ├── ServiceStoreManager optional service install/remove ├── CaddyManager Caddyfile generation and reload ├── AuthManager session auth, RBAC └── SetupManager first-run wizard state ``` Six core containers run on a Docker bridge network (`cell-network`, `172.20.0.0/16` default): `cell-caddy`, `cell-dns`, `cell-ntp`, `cell-wireguard`, `cell-api`, `cell-webui`. Static IPs per container are defined in `docker-compose.yml`. Installed optional services join the same network via their own compose projects, managed by `ServiceComposer`. The WireGuard container runs unprivileged — it uses `NET_ADMIN` only and requires the WireGuard module on the host kernel (Linux 5.6+ or a loadable module). The `cell-api` and `cell-webui` images are slim builds. The CoreDNS image is pinned to a specific digest. Runtime configuration lives in `config/api/cell_config.json`, managed by `ConfigManager`. All service managers read and write through `ConfigManager`, which validates and backs up automatically. --- ## Service Managers All managers inherit `BaseServiceManager` (`api/base_service_manager.py`), which provides: - `get_status()` — current running state - `get_config()` / `update_config()` — config read/write - `test_connectivity()` — reachability check - `get_logs()` — last N lines from the service log - `restart_service()` — container restart via Docker SDK The `ServiceBus` (`api/service_bus.py`) handles pub/sub events between managers (e.g., `CONFIG_CHANGED`, `SERVICE_STARTED`). Dependencies are declared in the bus (wireguard depends on network; email depends on network and vault). ### Manager summary | Manager | Responsibilities | |---|---| | `NetworkManager` | CoreDNS zone files and split-horizon DNS, chrony NTP | | `WireGuardManager` | Key generation, `wg0.conf` generation, peer add/remove, route sync | | `PeerRegistry` | Peer registration, trust tracking, peer statistics | | `EmailManager` | docker-mailserver accounts, mailbox config, alias management | | `CalendarManager` | Radicale user/calendar/contacts lifecycle | | `FileManager` | WebDAV user directories, Filegator access | | `RoutingManager` | NAT rules, per-peer routing policy, fwmark-based exit routing | | `FirewallManager` | iptables INPUT/FORWARD/OUTPUT rule management | | `VaultManager` | Internal CA (self-signed root), TLS cert issue/revoke, Age public key | | `ContainerManager` | Docker container/image/volume management via SDK | | `CellLinkManager` | Site-to-site WireGuard links to other PIC cells, peer-sync protocol | | `ConnectivityManager` | Per-peer exit routing via WireGuard external, OpenVPN, Tor, sshuttle, or proxy (redsocks) | | `DDNSManager` | Public IP heartbeat, provider abstraction (pic_ngo, cloudflare, duckdns, noip, freedns) | | `ServiceStoreManager` | Fetch manifest index, install/remove optional services | | `CaddyManager` | Caddyfile generation, reload-on-change | | `AuthManager` | bcrypt password store, session management, admin/peer RBAC | | `SetupManager` | First-run wizard state, setup-complete flag | --- ## First-Run Wizard On first start, `SetupManager.is_setup_complete()` returns `False`. The `enforce_setup` before-request hook returns HTTP 428 for all `/api/*` requests except `/api/setup/*` and `/health`, redirecting clients to `/setup`. The wizard collects: - **Cell name** — used for hostnames and DDNS subdomain (e.g. `myhome` → `myhome.pic.ngo`) - **Domain mode** — determines TLS certificate source: `lan` (internal CA), `pic_ngo`, `cloudflare`, `duckdns`, `http01` - **Timezone** - **Services to install** — optional services (email, calendar, files) to install after setup; each starts a background install via `ServiceStoreManager` - **Admin password** — minimum 12 characters Domain modes and their TLS behavior: | Mode | Certificate | |---|---| | `pic_ngo` | Wildcard Let's Encrypt cert via DNS-01 (requires accurate host clock for ACME + DDNS token) | | `cloudflare` | Wildcard Let's Encrypt cert via Cloudflare DNS-01 | | `duckdns` | Let's Encrypt via DuckDNS DNS-01 | | `http01` | Let's Encrypt per-subdomain cert via HTTP-01 (no wildcard; port 80 must be reachable) | | `lan` | Internal CA only; no internet required | On completion: 1. Admin account is created in `data/auth_users.json` 2. Cell identity is written to `config/api/cell_config.json` 3. Caddy config is generated 4. If domain mode is `pic_ngo`, the cell registers `.pic.ngo` with the DDNS service 5. Each selected service is installed in a background thread Wizard endpoints: `GET/POST /api/setup/step`, `GET /api/setup/status`, `POST /api/setup/complete`. --- ## Authentication `AuthManager` stores bcrypt-hashed credentials in `data/auth_users.json`. Two roles: | Role | Access | |---|---| | `admin` | All `/api/*` endpoints except `/api/peer/*` | | `peer` | `/api/peer/*` only (peer dashboard, key exchange) | Session auth flow: - `POST /api/auth/login` — creates a Flask session - `GET /api/auth/me` — current session info - `POST /api/auth/logout` — clears session - `POST /api/auth/change-password` — change own password - `POST /api/auth/admin/reset-password` — admin resets another user's password CSRF protection: all `POST`, `PUT`, `DELETE`, `PATCH` on `/api/*` (except `/api/auth/*` and `/api/setup/*`) require the `X-CSRF-Token` header matching the session token, obtained via `GET /api/auth/csrf-token`. Cell-to-cell peer-sync endpoints (`/api/cells/peer-sync/*`) use source-IP + WireGuard public key auth, not session cookies. Auth enforcement is active once any user exists in the store. If the store is empty (fresh install before wizard), all requests bypass auth — `enforce_setup` already blocks them with 428. --- ## API Reference **Base URL:** `http://localhost:3000` **Auth:** session cookie (`X-CSRF-Token` header required for mutations) ### Core | Method | Path | Description | |---|---|---| | GET | `/health` | Health check (always public) | | GET | `/api/status` | All-service status summary | | GET | `/api/config` | Full cell config | | PUT | `/api/config` | Update cell config | | GET | `/api/health/history` | Recent health check history | ### Auth (`/api/auth/`) | Method | Path | Description | |---|---|---| | POST | `/api/auth/login` | Create session | | POST | `/api/auth/logout` | Destroy session | | GET | `/api/auth/me` | Current user info | | GET | `/api/auth/csrf-token` | Get CSRF token | | POST | `/api/auth/change-password` | Change own password | | POST | `/api/auth/admin/reset-password` | Admin: reset another user's password | | GET | `/api/auth/users` | Admin: list users | ### Setup (`/api/setup/`) | Method | Path | Description | |---|---|---| | GET | `/api/setup/status` | Setup complete flag + current step | | GET | `/api/setup/step` | Current wizard step data | | POST | `/api/setup/step` | Submit current step | | POST | `/api/setup/complete` | Finalize setup | ### Network Services (`/api/dns/`, `/api/ntp/`, `/api/network/`) DNS overview (effective domain, public records, internal records, per-mode actions), NTP status, network connectivity test. ### WireGuard (`/api/wireguard/`, `/api/peers/`) Peer add/remove, key generation, QR code export, per-peer routing policy, WireGuard status. ### Email (`/api/email/`) _(available when email service is installed)_ User account management, mailbox config, alias management, connectivity test. Returns HTTP 404 when the email service is not installed (except `/api/email/status`). ### Calendar (`/api/calendar/`) _(available when calendar service is installed)_ User, calendar, and contacts (CardDAV) management. Returns HTTP 404 when the calendar service is not installed (except `/api/calendar/status`). ### Files (`/api/files/`) _(available when files service is installed)_ WebDAV user management, file upload/download/delete, folder management. Returns HTTP 404 when the files service is not installed (except `/api/files/status`). ### Routing (`/api/routing/`) NAT rules, peer routes, exit node configuration. ### Vault (`/api/vault/`) Certificate issue/revoke, CA certificate, trust key management, Age public key. ### Containers (`/api/containers/`) List, start, stop, inspect containers; manage images and volumes. ### Cell Network (`/api/cells/`) List connected cells, add/remove cell links, peer-sync. ### Connectivity (`/api/connectivity/`) List exit nodes, configure WireGuard external / OpenVPN / Tor / sshuttle / proxy exits, assign per-peer exit policy, assign per-service egress. ### Service Store (`/api/store/`) List available services, install, remove. ### Logs (`/api/logs/`) Per-service log retrieval, log search, log statistics. --- ## DDNS `DDNSManager` maintains a `.pic.ngo` DNS A record pointing at the cell's public IP. A background thread runs every 5 minutes and calls `provider.update(token, ip)` only when the IP changes. Registration happens during the setup wizard (if domain mode is `pic_ngo`) via `provider.register(name, ip)`, which returns a bearer token stored in `data/api/.ddns_token`. DDNS config lives in `cell_config.json` under the top-level `ddns` key: ```json { "ddns": { "provider": "pic_ngo", "api_base_url": "https://ddns.pic.ngo", "totp_secret": "" } } ``` Registration requires a time-based OTP (`X-Register-OTP` header) derived from the shared `REGISTER_TOTP_SECRET` on the DDNS server. This prevents unauthorized subdomain registration. Supported providers: `pic_ngo`, `cloudflare`, `duckdns`, `noip`, `freedns`. --- ## Network Services and DNS ### Split-horizon DNS PIC operates a split-horizon DNS configuration for the cell domain. - **Outside the VPN** — the cell domain (e.g. `myhome.pic.ngo`) resolves to the public IP via the DDNS provider. - **Inside the VPN** — CoreDNS answers the same cell domain with the WireGuard internal IP of `cell-caddy`. Traffic therefore flows through the WireGuard tunnel and Caddy serves it on both the public and WireGuard interface. `NetworkManager.update_split_horizon_zone()` writes a zone file for the effective domain and reloads CoreDNS via SIGUSR1 whenever the cell name or domain mode changes. ### Network Services page The **Network Services** page (`/network`) shows a provider-aware DNS overview: - Current domain mode label and effective domain - Public DNS records (A records registered with the DDNS provider) - Service subdomains (shown for `pic_ngo` and `duckdns` modes) - Internal records served by CoreDNS on the WireGuard network - Per-mode action buttons (e.g. force-refresh DDNS, reload CoreDNS) - NTP status (running/stopped, current time source) DHCP has been removed from PIC. There is no `cell-dhcp` container and no DHCP configuration in the UI. The `cell-dns` container runs CoreDNS only. ### NTP The `cell-ntp` container runs chrony in a pinned Alpine image. It provides NTP to WireGuard peers that configure the cell as their NTP server. The installer also enables host-level chrony to keep the host clock accurate for ACME certificate issuance and DDNS TOTP tokens. --- ## Backup and Restore ### What `make backup` captures `make backup` creates `backups/cell-backup-.tar.gz` containing: - `config/` — all service configuration including `cell_config.json` - `data/` excluding logs (`data/logs/`) and internal config-backup snapshots (`data/api/config_backups/`) - `docker-compose.yml` and `Makefile` The archive is written mode `0600`. It contains key material (WireGuard keys, internal CA, vault fernet.key, admin credentials, DDNS token, cell links, Caddy ACME certs). Store it securely. Data volumes of installed store services (email mailboxes, calendar collections, file trees) are **not** included in `make backup`. They are captured by the API-driven backup described below. ### API-driven backup (`POST /api/config/backup`) The API backup captures everything `make backup` does, plus: - `.env` - The Caddyfile and Corefile (runtime-generated) - DNS zone files - WireGuard key material and live peer configs - Vault directory (CA, certificates, fernet.key, trust store) - Per-service connectivity configs (sshuttle keys, redsocks config, OpenVPN configs, WireGuard external config) - Auth users, Flask secret key, DDNS token, cell links, peer service credentials - Caddy issued ACME certificates and ACME state - Live service data volumes (streamed via `docker exec tar`) **Passphrase encryption**: pass `{"passphrase": "..."}` in the request body to encrypt the archive. The encrypted file is named `.tar.gz.age` and uses Fernet with an scrypt-derived key. The plaintext staging directory is removed immediately after encryption. Supply the same passphrase when calling `POST /api/config/restore/`. Both backup and encrypted archive files are written mode `0600`. ### Restore **From `make backup`:** ```bash tar -xzf backups/cell-backup-YYYYMMDD-HHMMSS.tar.gz make restart ``` **From API backup:** Call `POST /api/config/restore/` (with `{"passphrase": "..."}` for encrypted archives). The restore process: 1. Restores the vault first (fernet.key must be present before any encrypted secrets are read) 2. Restores identity, `.env`, WireGuard key material, cell links 3. Restores Caddy ACME certs, Caddyfile, Corefile, DNS zones 4. Restores connectivity configs, auth users, DDNS token 5. Restores service user account files 6. Reloads `cell_config.json` into memory 7. Restores live service data volumes (if service containers are running) 8. Calls `_reapply_runtime_state()` — regenerates Caddyfile and Corefile from the restored config and re-applies routing rules After an API restore, run `make restart` to ensure all containers pick up the restored configuration. --- ## Services UI ### Navigation The left-hand navigation contains a **Services** group. Both admin and peer users see it. Sub-items for installed services (Email, Calendar, Files, etc.) are added dynamically: the UI fetches `GET /api/services/active` on load and after each install/uninstall. Services not yet installed do not appear in the nav. Legacy paths redirect to their new canonical locations: | Old path | New path | |---|---| | `/email` | `/services/email` | | `/calendar` | `/services/calendar` | | `/files` | `/services/files` | | `/store` | `/services` | ### Services page (`/services`) A single unified catalog of all available services from the store index. Each card shows: - Service name, description, version - **Install** button (not installed) or **Uninstall** button (installed) - **Open** link for installed services (navigates to the service sub-page) - Running/stopped status dot for installed services The `pic-services-changed` custom DOM event is dispatched after install/uninstall, causing the nav to re-fetch active services immediately. ### Service sub-pages — admin view Each sub-page at `/services/email`, `/services/calendar`, and `/services/files` shows: 1. **Connection info** — hostnames, ports, and protocol details (e.g. IMAP/SMTP/Webmail, CalDAV/CardDAV, WebDAV/Filegator). 2. **Service status** — current running state fetched from the API. 3. **Users list** — accounts registered with that service. 4. **Inline config form** — editable fields for that service's settings. If the service is not installed, the page shows a `ServiceNotInstalledBanner` with a link to the catalog for admins, or a "contact your admin" message for peer users. All non-status API routes for uninstalled services return HTTP 404. Config forms save automatically with an 800 ms debounce after the last change. ### Service sub-pages — peer view Peers access the same URLs. The peer view shows only: - Connection info (hostnames, ports, copy buttons). - Personal credentials for that service, fetched from `/api/peer/*`. The config form and users list are not shown to peers. ### Settings page The Email, Calendar, and Files configuration forms have been removed from the Settings page. Settings now covers: Identity, DDNS, Network (DNS, NTP), WireGuard, Routing & Firewall, Vault & Trust, and Backup & Restore. ### Relevant API endpoints | Method | Path | Description | |---|---|---| | GET | `/api/services/active` | List installed services with id, name, subdomain, capabilities | | GET | `/api/config` | Full cell config, includes `installed_services` dict | --- ## Service Store (Add-ons) Email, calendar, and file storage are store services — not part of the core stack. All optional functionality ships through this mechanism. `ServiceStoreManager` fetches a manifest index from `https://git.pic.ngo/roof/pic-services/raw/branch/main/index.json`. Each manifest (`schema_version: 3`) declares: - Container image and compose template - Caddy subdomain routes - Capabilities: `has_subdomain`, `has_accounts`, `has_admin_config`, `has_storage`, `has_egress` - Account provisioning interface (`accounts.manager`) - Backup declarations (`backup.volumes`, `backup.config_paths`) - Egress routing policy (`egress.allowed`) - Per-peer connection info template (`peer_config_template`) `POST /api/store/install` fetches the manifest and compose template, validates them, renders the template with PIC-specific variables (`${PIC_DOMAIN}`, `${PIC_DATA_DIR}`, etc.), writes a per-service compose file, and brings the containers up via `ServiceComposer`. Caddy routes and DNS entries are applied automatically. `POST /api/store/remove` checks for dependent services, stops and removes containers, and regenerates Caddy. **`ServiceComposer`** (`api/service_composer.py`) manages the per-service compose lifecycle independently of the main stack. Each service gets its own compose project at `data/services//docker-compose.yml`. On startup, `reapply_active_services()` brings up containers for all recorded installs. See `docs/service-developer-guide.md` for the full manifest schema reference and submission process. --- ## Cell-to-Cell Networking `CellLinkManager` manages WireGuard site-to-site tunnels between PIC cells. Each link is a WireGuard peer configured with a dedicated `/32` address and allowed-IPs covering the remote cell's subnet. The peer-sync protocol (`/api/cells/peer-sync/`) exchanges public keys and allowed networks between cells using source-IP + WireGuard public key authentication (no session required). Access control is per-service (calendar, files, mail, WebDAV) and enforced at the iptables level. --- ## Extended Connectivity `ConnectivityManager` provides per-peer and per-service exit routing: traffic from a specific WireGuard peer (or a specific installed service) can be routed through an alternate exit instead of the cell's default gateway. All exit types are optional store services installed from the Services catalog. Each exit type corresponds to a store service ID: | Exit type | Store service | Mechanism | |---|---|---| | `wireguard_ext` | `wireguard-ext` | WireGuard client tunnel to an external server; iface `wg_ext0`; fwmark `0x10`, table 110 | | `openvpn` | `openvpn-client` | OpenVPN client tunnel; iface `tun0`; fwmark `0x20`, table 120 | | `tor` | `tor` | Transparent proxy → Tor SOCKS on port 9040; fwmark `0x30`, table 130 | | `sshuttle` | `sshuttle` | SSH tunnel via sshuttle to any SSH server; transparent proxy on port 12300; fwmark `0x40`, table 140 | | `proxy` | `proxy` | HTTP or SOCKS5 upstream proxy via redsocks transparent redirection on port 12345; fwmark `0x50`, table 150 | Routing uses fwmark and `ip rule` / `ip route` in separate routing tables inside the `cell-wireguard` container. A kill-switch FORWARD rule drops traffic for configured exits if the exit container is not active. Configuration is via `PUT /api/connectivity/peers//exit`. Service-level egress is declared in the service manifest's `egress` block and configured via the Connectivity page in the UI. --- ## Security Model - **No open ports for the API** — Flask API binds to `127.0.0.1:3000` only; Caddy proxies HTTPS requests to it. - **Session auth** — bcrypt passwords, Flask server-side sessions, CSRF double-submit. - **Setup wizard gate** — all `/api/*` requests return 428 until setup is complete. - **Role separation** — admin cannot access peer endpoints; peer cannot access admin endpoints. - **HTTPS everywhere** — Caddy handles TLS; internal services are reached via reverse proxy paths. - **Internal CA** — VaultManager issues certificates for services that don't use Let's Encrypt. - **Docker socket isolation** — the Docker socket is mounted only into `cell-api`; other containers have no Docker access. - **iptables firewall** — FirewallManager manages INPUT/FORWARD rules; WireGuard peer isolation is enforced at the packet level. --- ## Testing ```bash make test # unit tests (pytest, ~1900+ functions) make test-coverage # coverage report in htmlcov/ ``` Test layout: - `tests/` — unit and endpoint tests; no running services required - `tests/integration/` — require a running PIC stack - `tests/e2e/` — Playwright UI tests and WireGuard integration tests CI: Gitea Actions runs `pytest tests/ --ignore=tests/e2e --ignore=tests/integration` on every push. --- ## Development ```bash # Full stack in Docker make start make stop make logs # Flask API without Docker (port 3000) pip install -r api/requirements.txt python api/app.py # React UI dev server (port 5173, proxies /api → :3000) cd webui && npm install && npm run dev # Rebuild containers after code change make build-api make build-webui ``` Key files: - `api/app.py` — Flask app, blueprint registration, before-request hooks, health monitor thread - `api/managers.py` — singleton instantiation of all service managers - `api/base_service_manager.py` — abstract base class all managers implement - `api/config_manager.py` — `cell_config.json` read/write/validate/backup - `api/service_bus.py` — pub/sub event system - `webui/src/services/api.js` — Axios API client used by all UI pages - `docker-compose.yml` — container definitions and network topology - `Makefile` — all operational commands