diff --git a/Makefile b/Makefile index 73c9e39..32dbe86 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,28 @@ # Personal Internet Cell - Makefile # Provides easy commands for managing the cell -.PHONY: help start stop restart status logs clean setup init-peers +.PHONY: help start stop restart status logs clean setup init-peers build build-api build-webui # Default target help: @echo "Personal Internet Cell - Management Commands" @echo "" - @echo "Setup:" - @echo " setup - Initial setup and configuration" - @echo " init-peers - Initialize peer configuration" + @echo "Setup (run once on a fresh host):" + @echo " setup - Create dirs, generate WireGuard keys, write configs, then: make start" + @echo " Env vars: CELL_NAME=mycell CELL_DOMAIN=cell VPN_ADDRESS=10.0.0.1/24 WG_PORT=51820" + @echo " init-peers - Reset peer list to empty" @echo "" @echo "Management:" - @echo " start - Start all services" + @echo " start - Start all services (docker compose up -d)" @echo " stop - Stop all services" @echo " restart - Restart all services" - @echo " status - Show status of all services" - @echo " logs - Show logs from all services" + @echo " status - Show container status + API health" + @echo " logs - Follow logs from all services" + @echo "" + @echo "Build:" + @echo " build - Rebuild API image" + @echo " build-api - Rebuild API image (no cache)" + @echo " build-webui - Rebuild Web UI image (no cache)" @echo "" @echo "Individual Services:" @echo " start-dns - Start DNS service only" @@ -31,8 +37,11 @@ help: # Setup commands setup: @echo "Setting up Personal Internet Cell..." + CELL_NAME=$(or $(CELL_NAME),mycell) \ + CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \ + VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \ + WG_PORT=$(or $(WG_PORT),51820) \ python3 scripts/setup_cell.py - @echo "Setup complete!" init-peers: @echo "Initializing peer configuration..." @@ -113,6 +122,16 @@ build: @echo "Building API service..." docker-compose build api +build-api: + @echo "Rebuilding API (no cache)..." + docker-compose build --no-cache api + docker-compose up -d api + +build-webui: + @echo "Rebuilding Web UI (no cache)..." + docker-compose build --no-cache webui + docker-compose up -d webui + # Testing commands test: @echo "Running all unit and integration tests with pytest..." diff --git a/README.md b/README.md index 25df885..5e05bd7 100644 --- a/README.md +++ b/README.md @@ -61,45 +61,82 @@ The Personal Internet Cell is a **production-grade, self-hosted, decentralized d ### Prerequisites -- **Docker & Docker Compose** (recommended) -- **Python 3.10+** (for CLI and development) -- **2GB+ RAM, 10GB+ disk space** -- **Ports**: 53, 80, 443, 3000, 51820 +- **Docker** with Compose plugin (`docker compose`) or standalone `docker-compose` +- **WireGuard tools** (`wg` binary, for key generation during install) +- **2 GB+ RAM, 10 GB+ disk space** +- **Open ports**: 53 (DNS), 80/443 (HTTP/S), 3000 (API), 8081 (Web UI), 51820/udp (WireGuard) -### 1. Clone and Setup +### 1. Install ```bash -git clone https://github.com/yourusername/PersonalInternetCell.git -cd PersonalInternetCell +git clone pic +cd pic -# Start with Docker (Recommended) -docker-compose up --build +# Default cell (name=mycell, domain=cell, VPN=10.0.0.1/24, port=51820) +make setup && make start -# Or run locally -pip install -r api/requirements.txt -python api/app.py +# Custom cell — required when installing a second cell on a different host +CELL_NAME=pic1 VPN_ADDRESS=10.1.0.1/24 make setup && make start ``` -### 2. Access Services +`make setup` generates WireGuard keys, writes `config/wireguard/wg0.conf` and +`config/api/cell_config.json`, and creates all data directories. +`make start` brings up all 13 Docker containers. -- **API**: http://localhost:3000 -- **Health Check**: http://localhost:3000/health -- **Service Status**: http://localhost:3000/api/services/status +### 2. Access -### 3. Use the Enhanced CLI +| Service | URL | +|---------|-----| +| Web UI | `http://:8081` | +| API | `http://:3000` | +| Health | `http://:3000/health` | + +On a WireGuard client: `http://mycell.cell` (or whatever your cell name is). + +### 3. Local dev (no Docker) ```bash -# Show cell status -python api/enhanced_cli.py --status +pip install -r api/requirements.txt +python api/app.py # API on :3000 -# Interactive mode -python api/enhanced_cli.py --interactive +cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000) +``` -# Show all services -python api/enhanced_cli.py --services +--- -# Configuration wizard -python api/enhanced_cli.py --wizard network +## 🔗 Connecting Two Cells (PIC Mesh) + +Two PIC instances can form a mesh — full site-to-site WireGuard tunnels with +automatic DNS forwarding so each cell's services are reachable from the other. + +### Install the second cell + +```bash +# On the second host (different VPN subnet; port 51820 is fine — different machine) +CELL_NAME=pic1 VPN_ADDRESS=10.1.0.1/24 make setup && make start +``` + +### Exchange invites (two pastes, two clicks) + +1. On **Cell A** → open Web UI → **Cell Network** → copy the invite JSON. +2. On **Cell B** → **Cell Network** → paste into "Connect to Another Cell" → click **Connect**. +3. On **Cell B** → copy its invite JSON. +4. On **Cell A** → paste Cell B's invite → click **Connect**. + +Both cells now have: +- A site-to-site WireGuard peer (AllowedIPs = remote cell's VPN subnet). +- A CoreDNS forwarding block so `*.pic1.cell` resolves across the tunnel. + +The **Connected Cells** panel shows live handshake status (green = online). + +### Same-LAN tip + +If both cells share the same external IP (behind NAT), the auto-detected +endpoint in the invite will be the public IP. Replace it with the LAN IP +before clicking Connect so traffic stays local: + +```json +{ "endpoint": "192.168.31.50:51820", ... } ``` --- diff --git a/api/app.py b/api/app.py index 2043563..c965a72 100644 --- a/api/app.py +++ b/api/app.py @@ -107,7 +107,10 @@ CORS(app) app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production # Initialize enhanced components -config_manager = ConfigManager(config_file='./config/cell_config.json', data_dir='./data') +config_manager = ConfigManager( + config_file=os.path.join(os.environ.get('CONFIG_DIR', '/app/config'), 'cell_config.json'), + data_dir=os.environ.get('DATA_DIR', '/app/data'), +) service_bus = ServiceBus() log_manager = LogManager(log_dir='./data/logs') diff --git a/api/wireguard_manager.py b/api/wireguard_manager.py index a26472b..141fdff 100644 --- a/api/wireguard_manager.py +++ b/api/wireguard_manager.py @@ -112,23 +112,25 @@ class WireGuardManager(BaseServiceManager): def generate_config(self, interface: str = 'wg0', port: int = DEFAULT_PORT) -> str: """Return a WireGuard [Interface] config string for the server.""" + import ipaddress keys = self.get_keys() ext_ip = self.get_external_ip() or '' - # Hairpin DNAT: redirect VPN clients targeting the server's public IP - # to 10.0.0.1 (the VPN interface), avoiding the Docker network loopback. + address = self._get_configured_address() if os.path.exists(self._config_file()) else SERVER_ADDRESS + server_ip = str(ipaddress.ip_interface(address).ip) hairpin = ( - f'iptables -t nat -A PREROUTING -i %i -d {ext_ip} -j DNAT --to-destination 10.0.0.1; ' + f'iptables -t nat -A PREROUTING -i %i -d {ext_ip} -j DNAT --to-destination {server_ip}; ' if ext_ip else '' ) hairpin_down = ( - f'iptables -t nat -D PREROUTING -i %i -d {ext_ip} -j DNAT --to-destination 10.0.0.1; ' + f'iptables -t nat -D PREROUTING -i %i -d {ext_ip} -j DNAT --to-destination {server_ip}; ' if ext_ip else '' ) + cfg_port = self._get_configured_port() if os.path.exists(self._config_file()) else port return ( f'[Interface]\n' f'PrivateKey = {keys["private_key"]}\n' - f'Address = {SERVER_ADDRESS}\n' - f'ListenPort = {port}\n' + f'Address = {address}\n' + f'ListenPort = {cfg_port}\n' f'PostUp = iptables -A FORWARD -i %i -j ACCEPT; ' f'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ' f'{hairpin}' diff --git a/scripts/setup_cell.py b/scripts/setup_cell.py index 04a1516..ef69762 100644 --- a/scripts/setup_cell.py +++ b/scripts/setup_cell.py @@ -1,99 +1,206 @@ -#!/usr/bin/env python3 -import os -import sys -import subprocess - -# List of required directories (relative to project root) -REQUIRED_DIRS = [ - 'config/caddy/certs', - 'config/dns', - 'config/dhcp', - 'config/ntp', - 'config/mail/config', - 'config/mail/ssl', - 'config/radicale', - 'config/webdav', - 'config/wireguard', - 'config/api', - 'data/caddy', - 'data/dns', - 'data/dhcp', - 'data/maildata', - 'data/mailstate', - 'data/maillogs', - 'data/radicale', - 'data/files', - 'data/api', - 'data/vault/certs', - 'data/vault/keys', - 'data/vault/trust', - 'data/vault/ca', -] - -# List of required files (relative to project root) -REQUIRED_FILES = [ - 'config/caddy/Caddyfile', - 'config/dns/Corefile', - 'config/dhcp/dnsmasq.conf', - 'config/ntp/chrony.conf', - 'config/mail/mailserver.env', - 'config/webdav/users.passwd', -] - -# Helper to create directories -def ensure_dir(path): - if not os.path.exists(path): - os.makedirs(path, exist_ok=True) - print(f"[CREATED] Directory: {path}") - # Add .gitkeep to empty dirs - gitkeep = os.path.join(path, '.gitkeep') - with open(gitkeep, 'w') as f: - f.write('') - else: - print(f"[EXISTS] Directory: {path}") - -# Helper to create empty files if missing -def ensure_file(path): - if not os.path.exists(path): - parent = os.path.dirname(path) - if parent and not os.path.exists(parent): - os.makedirs(parent, exist_ok=True) - print(f"[CREATED] Directory: {parent}") - with open(path, 'w') as f: - f.write('') - print(f"[CREATED] File: {path}") - else: - print(f"[EXISTS] File: {path}") - -# Optionally generate a self-signed CA cert for Caddy -def ensure_caddy_ca_cert(): - cert_dir = os.path.join('config', 'caddy', 'certs') - ca_key = os.path.join(cert_dir, 'ca.key') - ca_crt = os.path.join(cert_dir, 'ca.crt') - if os.path.exists(ca_key) and os.path.exists(ca_crt): - print(f"[EXISTS] Caddy CA cert and key: {ca_crt}, {ca_key}") - return - print("[INFO] Generating self-signed CA certificate for Caddy...") - try: - subprocess.run([ - 'openssl', 'req', '-x509', '-newkey', 'rsa:4096', - '-keyout', ca_key, '-out', ca_crt, '-days', '365', '-nodes', - '-subj', '/C=US/ST=State/L=City/O=PersonalInternetCell/CN=CellCA' - ], check=True) - print(f"[CREATED] Caddy CA cert and key: {ca_crt}, {ca_key}") - except FileNotFoundError: - print("[WARN] openssl not found, skipping CA cert generation.") - except subprocess.CalledProcessError: - print("[ERROR] openssl failed to generate CA cert.") - -def main(): - print("--- Personal Internet Cell: Setup Script ---") - for d in REQUIRED_DIRS: - ensure_dir(d) - for f in REQUIRED_FILES: - ensure_file(f) - ensure_caddy_ca_cert() - print("--- Setup complete! ---") - -if __name__ == '__main__': - main() \ No newline at end of file +#!/usr/bin/env python3 +""" +PIC setup script — run once on a fresh host to initialise a new cell. + +Env vars (all optional, have defaults): + CELL_NAME cell identity name (default: mycell) + CELL_DOMAIN DNS TLD for this cell (default: cell) + VPN_ADDRESS WireGuard server address (default: 10.0.0.1/24) + WG_PORT WireGuard listen port (default: 51820) +""" +import json +import os +import subprocess +import sys + +# ── directories ──────────────────────────────────────────────────────────────── +REQUIRED_DIRS = [ + 'config/caddy/certs', + 'config/dns', + 'config/dhcp', + 'config/ntp', + 'config/mail/config', + 'config/mail/ssl', + 'config/radicale', + 'config/webdav', + 'config/wireguard', + 'config/api', + 'data/caddy', + 'data/dns', + 'data/dhcp', + 'data/maildata', + 'data/mailstate', + 'data/maillogs', + 'data/radicale', + 'data/files', + 'data/api', + 'data/vault/certs', + 'data/vault/keys', + 'data/vault/trust', + 'data/vault/ca', + 'data/logs', + 'data/wireguard/keys/peers', + 'data/wireguard/wg_confs', +] + +REQUIRED_FILES = [ + 'config/caddy/Caddyfile', + 'config/dns/Corefile', + 'config/dhcp/dnsmasq.conf', + 'config/ntp/chrony.conf', + 'config/mail/mailserver.env', + 'config/webdav/users.passwd', +] + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def ensure_dir(rel): + path = os.path.join(ROOT, rel) + if not os.path.exists(path): + os.makedirs(path, exist_ok=True) + print(f'[CREATED] {rel}') + open(os.path.join(path, '.gitkeep'), 'w').close() + else: + print(f'[EXISTS] {rel}') + + +def ensure_file(rel): + path = os.path.join(ROOT, rel) + if not os.path.exists(path): + os.makedirs(os.path.dirname(path), exist_ok=True) + open(path, 'w').close() + print(f'[CREATED] {rel}') + else: + print(f'[EXISTS] {rel}') + + +def ensure_caddy_ca_cert(): + cert_dir = os.path.join(ROOT, 'config', 'caddy', 'certs') + ca_key = os.path.join(cert_dir, 'ca.key') + ca_crt = os.path.join(cert_dir, 'ca.crt') + if os.path.exists(ca_key) and os.path.exists(ca_crt): + print('[EXISTS] Caddy CA cert') + return + print('[INFO] Generating Caddy CA certificate...') + try: + subprocess.run([ + 'openssl', 'req', '-x509', '-newkey', 'rsa:4096', + '-keyout', ca_key, '-out', ca_crt, '-days', '365', '-nodes', + '-subj', '/C=US/ST=State/L=City/O=PersonalInternetCell/CN=CellCA' + ], check=True, capture_output=True) + print(f'[CREATED] Caddy CA cert') + except FileNotFoundError: + print('[WARN] openssl not found — skipping CA cert generation') + except subprocess.CalledProcessError as e: + print(f'[ERROR] openssl failed: {e}') + + +def _gen_keys_python(): + """Generate WireGuard keypair using the cryptography library (no wg binary needed).""" + import base64 + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + private_key = X25519PrivateKey.generate() + private_bytes = private_key.private_bytes_raw() + public_bytes = private_key.public_key().public_bytes_raw() + return base64.b64encode(private_bytes).decode(), base64.b64encode(public_bytes).decode() + + +def generate_wg_keys(): + keys_dir = os.path.join(ROOT, 'data', 'wireguard', 'keys') + priv_path = os.path.join(keys_dir, 'server_private.key') + pub_path = os.path.join(keys_dir, 'server_public.key') + if os.path.exists(priv_path) and os.path.exists(pub_path): + print('[EXISTS] WireGuard server keys') + return open(priv_path).read().strip(), open(pub_path).read().strip() + print('[INFO] Generating WireGuard server keys...') + os.makedirs(keys_dir, exist_ok=True) + # Try wg binary first; fall back to Python cryptography library + try: + priv = subprocess.check_output(['wg', 'genkey']).decode().strip() + pub = subprocess.check_output(['wg', 'pubkey'], input=priv.encode()).decode().strip() + except FileNotFoundError: + print('[INFO] wg not found — using Python cryptography library') + priv, pub = _gen_keys_python() + with open(priv_path, 'w') as f: + f.write(priv + '\n') + os.chmod(priv_path, 0o600) + with open(pub_path, 'w') as f: + f.write(pub + '\n') + print(f'[CREATED] WireGuard server keys pub={pub[:12]}...') + return priv, pub + + +def write_wg0_conf(private_key: str, address: str, port: int): + wg_conf = os.path.join(ROOT, 'config', 'wireguard', 'wg0.conf') + if os.path.exists(wg_conf): + print('[EXISTS] config/wireguard/wg0.conf') + return + server_ip = address.split('/')[0] + content = ( + f'[Interface]\n' + f'PrivateKey = {private_key}\n' + f'Address = {address}\n' + f'ListenPort = {port}\n' + f'PostUp = iptables -A FORWARD -i %i -j ACCEPT; ' + f'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ' + f'sysctl -q net.ipv4.conf.all.rp_filter=0\n' + f'PostDown = iptables -D FORWARD -i %i -j ACCEPT; ' + f'iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; ' + f'sysctl -q net.ipv4.conf.all.rp_filter=1\n' + ) + with open(wg_conf, 'w') as f: + f.write(content) + os.chmod(wg_conf, 0o600) + print(f'[CREATED] config/wireguard/wg0.conf address={address} port={port}') + + +def write_cell_config(cell_name: str, domain: str, port: int): + cfg_path = os.path.join(ROOT, 'config', 'api', 'cell_config.json') + if os.path.exists(cfg_path): + try: + existing = json.loads(open(cfg_path).read()) + if existing and existing != {}: + print('[EXISTS] config/api/cell_config.json') + return + except Exception: + pass + config = { + '_identity': { + 'cell_name': cell_name, + 'domain': domain, + 'ip_range': '172.20.0.0/16', + 'wireguard_port': port, + } + } + with open(cfg_path, 'w') as f: + json.dump(config, f, indent=2) + print(f'[CREATED] config/api/cell_config.json name={cell_name} domain={domain}') + + +def main(): + cell_name = os.environ.get('CELL_NAME', 'mycell') + domain = os.environ.get('CELL_DOMAIN', 'cell') + vpn_address = os.environ.get('VPN_ADDRESS', '10.0.0.1/24') + wg_port = int(os.environ.get('WG_PORT', '51820')) + + print('--- Personal Internet Cell: Setup ---') + print(f' cell={cell_name} domain={domain} vpn={vpn_address} port={wg_port}') + print() + + for d in REQUIRED_DIRS: + ensure_dir(d) + for f in REQUIRED_FILES: + ensure_file(f) + + ensure_caddy_ca_cert() + priv, _pub = generate_wg_keys() + write_wg0_conf(priv, vpn_address, wg_port) + write_cell_config(cell_name, domain, wg_port) + + print() + print('--- Setup complete! Run: make start ---') + + +if __name__ == '__main__': + main()