fix: config persistence, setup script, and install docs

- app.py: ConfigManager now uses CONFIG_DIR env var for config file path
  instead of hardcoded './config/cell_config.json' — config was being read
  from the image's working directory, making all settings writes ephemeral
  (lost on container restart)
- wireguard_manager: generate_config uses configured address/port instead of
  hardcoded 10.0.0.1 in DNAT rules and Address field
- scripts/setup_cell.py: full setup script — generates WireGuard keys (wg
  binary or Python cryptography fallback), writes wg0.conf and cell_config.json
  with correct _identity key; CELL_NAME / VPN_ADDRESS / WG_PORT env vars
- Makefile: setup target passes env vars through; build-api / build-webui targets
- README: replace install.sh references with make setup && make start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 07:37:11 -04:00
parent 848f8cfc7c
commit 4ed2a6cbae
5 changed files with 307 additions and 139 deletions
+27 -8
View File
@@ -1,22 +1,28 @@
# Personal Internet Cell - Makefile # Personal Internet Cell - Makefile
# Provides easy commands for managing the cell # 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 # Default target
help: help:
@echo "Personal Internet Cell - Management Commands" @echo "Personal Internet Cell - Management Commands"
@echo "" @echo ""
@echo "Setup:" @echo "Setup (run once on a fresh host):"
@echo " setup - Initial setup and configuration" @echo " setup - Create dirs, generate WireGuard keys, write configs, then: make start"
@echo " init-peers - Initialize peer configuration" @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 ""
@echo "Management:" @echo "Management:"
@echo " start - Start all services" @echo " start - Start all services (docker compose up -d)"
@echo " stop - Stop all services" @echo " stop - Stop all services"
@echo " restart - Restart all services" @echo " restart - Restart all services"
@echo " status - Show status of all services" @echo " status - Show container status + API health"
@echo " logs - Show logs from all services" @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 ""
@echo "Individual Services:" @echo "Individual Services:"
@echo " start-dns - Start DNS service only" @echo " start-dns - Start DNS service only"
@@ -31,8 +37,11 @@ help:
# Setup commands # Setup commands
setup: setup:
@echo "Setting up Personal Internet Cell..." @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 python3 scripts/setup_cell.py
@echo "Setup complete!"
init-peers: init-peers:
@echo "Initializing peer configuration..." @echo "Initializing peer configuration..."
@@ -113,6 +122,16 @@ build:
@echo "Building API service..." @echo "Building API service..."
docker-compose build api 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 # Testing commands
test: test:
@echo "Running all unit and integration tests with pytest..." @echo "Running all unit and integration tests with pytest..."
+62 -25
View File
@@ -61,45 +61,82 @@ The Personal Internet Cell is a **production-grade, self-hosted, decentralized d
### Prerequisites ### Prerequisites
- **Docker & Docker Compose** (recommended) - **Docker** with Compose plugin (`docker compose`) or standalone `docker-compose`
- **Python 3.10+** (for CLI and development) - **WireGuard tools** (`wg` binary, for key generation during install)
- **2GB+ RAM, 10GB+ disk space** - **2 GB+ RAM, 10 GB+ disk space**
- **Ports**: 53, 80, 443, 3000, 51820 - **Open ports**: 53 (DNS), 80/443 (HTTP/S), 3000 (API), 8081 (Web UI), 51820/udp (WireGuard)
### 1. Clone and Setup ### 1. Install
```bash ```bash
git clone https://github.com/yourusername/PersonalInternetCell.git git clone <repo-url> pic
cd PersonalInternetCell cd pic
# Start with Docker (Recommended) # Default cell (name=mycell, domain=cell, VPN=10.0.0.1/24, port=51820)
docker-compose up --build make setup && make start
# Or run locally # Custom cell — required when installing a second cell on a different host
pip install -r api/requirements.txt CELL_NAME=pic1 VPN_ADDRESS=10.1.0.1/24 make setup && make start
python api/app.py
``` ```
### 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 ### 2. Access
- **Health Check**: http://localhost:3000/health
- **Service Status**: http://localhost:3000/api/services/status
### 3. Use the Enhanced CLI | Service | URL |
|---------|-----|
| Web UI | `http://<host-ip>:8081` |
| API | `http://<host-ip>:3000` |
| Health | `http://<host-ip>:3000/health` |
On a WireGuard client: `http://mycell.cell` (or whatever your cell name is).
### 3. Local dev (no Docker)
```bash ```bash
# Show cell status pip install -r api/requirements.txt
python api/enhanced_cli.py --status python api/app.py # API on :3000
# Interactive mode cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000)
python api/enhanced_cli.py --interactive ```
# Show all services ---
python api/enhanced_cli.py --services
# Configuration wizard ## 🔗 Connecting Two Cells (PIC Mesh)
python api/enhanced_cli.py --wizard network
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", ... }
``` ```
--- ---
+4 -1
View File
@@ -107,7 +107,10 @@ CORS(app)
app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production app.config['DEVELOPMENT_MODE'] = True # Set to True for development, False for production
# Initialize enhanced components # 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() service_bus = ServiceBus()
log_manager = LogManager(log_dir='./data/logs') log_manager = LogManager(log_dir='./data/logs')
+8 -6
View File
@@ -112,23 +112,25 @@ class WireGuardManager(BaseServiceManager):
def generate_config(self, interface: str = 'wg0', port: int = DEFAULT_PORT) -> str: def generate_config(self, interface: str = 'wg0', port: int = DEFAULT_PORT) -> str:
"""Return a WireGuard [Interface] config string for the server.""" """Return a WireGuard [Interface] config string for the server."""
import ipaddress
keys = self.get_keys() keys = self.get_keys()
ext_ip = self.get_external_ip() or '' ext_ip = self.get_external_ip() or ''
# Hairpin DNAT: redirect VPN clients targeting the server's public IP address = self._get_configured_address() if os.path.exists(self._config_file()) else SERVER_ADDRESS
# to 10.0.0.1 (the VPN interface), avoiding the Docker network loopback. server_ip = str(ipaddress.ip_interface(address).ip)
hairpin = ( 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 '' if ext_ip else ''
) )
hairpin_down = ( 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 '' if ext_ip else ''
) )
cfg_port = self._get_configured_port() if os.path.exists(self._config_file()) else port
return ( return (
f'[Interface]\n' f'[Interface]\n'
f'PrivateKey = {keys["private_key"]}\n' f'PrivateKey = {keys["private_key"]}\n'
f'Address = {SERVER_ADDRESS}\n' f'Address = {address}\n'
f'ListenPort = {port}\n' f'ListenPort = {cfg_port}\n'
f'PostUp = iptables -A FORWARD -i %i -j ACCEPT; ' f'PostUp = iptables -A FORWARD -i %i -j ACCEPT; '
f'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ' f'iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; '
f'{hairpin}' f'{hairpin}'
+141 -34
View File
@@ -1,9 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os """
import sys PIC setup script run once on a fresh host to initialise a new cell.
import subprocess
# List of required directories (relative to project root) 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 = [ REQUIRED_DIRS = [
'config/caddy/certs', 'config/caddy/certs',
'config/dns', 'config/dns',
@@ -28,9 +38,11 @@ REQUIRED_DIRS = [
'data/vault/keys', 'data/vault/keys',
'data/vault/trust', 'data/vault/trust',
'data/vault/ca', 'data/vault/ca',
'data/logs',
'data/wireguard/keys/peers',
'data/wireguard/wg_confs',
] ]
# List of required files (relative to project root)
REQUIRED_FILES = [ REQUIRED_FILES = [
'config/caddy/Caddyfile', 'config/caddy/Caddyfile',
'config/dns/Corefile', 'config/dns/Corefile',
@@ -40,60 +52,155 @@ REQUIRED_FILES = [
'config/webdav/users.passwd', 'config/webdav/users.passwd',
] ]
# Helper to create directories ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def ensure_dir(path):
def ensure_dir(rel):
path = os.path.join(ROOT, rel)
if not os.path.exists(path): if not os.path.exists(path):
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
print(f"[CREATED] Directory: {path}") print(f'[CREATED] {rel}')
# Add .gitkeep to empty dirs open(os.path.join(path, '.gitkeep'), 'w').close()
gitkeep = os.path.join(path, '.gitkeep')
with open(gitkeep, 'w') as f:
f.write('')
else: else:
print(f"[EXISTS] Directory: {path}") print(f'[EXISTS] {rel}')
# Helper to create empty files if missing
def ensure_file(path): def ensure_file(rel):
path = os.path.join(ROOT, rel)
if not os.path.exists(path): if not os.path.exists(path):
parent = os.path.dirname(path) os.makedirs(os.path.dirname(path), exist_ok=True)
if parent and not os.path.exists(parent): open(path, 'w').close()
os.makedirs(parent, exist_ok=True) print(f'[CREATED] {rel}')
print(f"[CREATED] Directory: {parent}")
with open(path, 'w') as f:
f.write('')
print(f"[CREATED] File: {path}")
else: else:
print(f"[EXISTS] File: {path}") print(f'[EXISTS] {rel}')
# Optionally generate a self-signed CA cert for Caddy
def ensure_caddy_ca_cert(): def ensure_caddy_ca_cert():
cert_dir = os.path.join('config', 'caddy', 'certs') cert_dir = os.path.join(ROOT, 'config', 'caddy', 'certs')
ca_key = os.path.join(cert_dir, 'ca.key') ca_key = os.path.join(cert_dir, 'ca.key')
ca_crt = os.path.join(cert_dir, 'ca.crt') ca_crt = os.path.join(cert_dir, 'ca.crt')
if os.path.exists(ca_key) and os.path.exists(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}") print('[EXISTS] Caddy CA cert')
return return
print("[INFO] Generating self-signed CA certificate for Caddy...") print('[INFO] Generating Caddy CA certificate...')
try: try:
subprocess.run([ subprocess.run([
'openssl', 'req', '-x509', '-newkey', 'rsa:4096', 'openssl', 'req', '-x509', '-newkey', 'rsa:4096',
'-keyout', ca_key, '-out', ca_crt, '-days', '365', '-nodes', '-keyout', ca_key, '-out', ca_crt, '-days', '365', '-nodes',
'-subj', '/C=US/ST=State/L=City/O=PersonalInternetCell/CN=CellCA' '-subj', '/C=US/ST=State/L=City/O=PersonalInternetCell/CN=CellCA'
], check=True) ], check=True, capture_output=True)
print(f"[CREATED] Caddy CA cert and key: {ca_crt}, {ca_key}") print(f'[CREATED] Caddy CA cert')
except FileNotFoundError: except FileNotFoundError:
print("[WARN] openssl not found, skipping CA cert generation.") print('[WARN] openssl not found skipping CA cert generation')
except subprocess.CalledProcessError: except subprocess.CalledProcessError as e:
print("[ERROR] openssl failed to generate CA cert.") 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(): def main():
print("--- Personal Internet Cell: Setup Script ---") 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: for d in REQUIRED_DIRS:
ensure_dir(d) ensure_dir(d)
for f in REQUIRED_FILES: for f in REQUIRED_FILES:
ensure_file(f) ensure_file(f)
ensure_caddy_ca_cert() ensure_caddy_ca_cert()
print("--- Setup complete! ---") 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__': if __name__ == '__main__':
main() main()