Files
pic/Makefile
T
roof a9c7235347
Unit Tests / test (push) Successful in 12m0s
fix: install chrony for host NTP and enable pic.service on cold install
Root-cause fix for ACME failures caused by clock drift breaking TOTP
during DDNS registration: install and start chrony (all supported package
managers) before the setup wizard runs, so the host clock is accurate from
day one.

Also enables and starts the pic systemd unit at the end of a cold install —
previously the unit file was written but never activated, so the stack would
not survive a reboot without a manual `systemctl enable --now pic`.

Makefile uninstall hardened: `disable --now` instead of bare `disable` so the
running unit is stopped before the unit file is removed; daemon-reload called
afterwards to flush the stale unit; and all lingering cell-* containers
(tor/sshuttle/redsocks/store services) are now force-removed so subsequent
reinstalls start from a clean Docker state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:38:03 -04:00

380 lines
16 KiB
Makefile

# Personal Internet Cell - Makefile
# Provides easy commands for managing the cell
.PHONY: help start stop restart status logs clean setup check-deps init-peers \
update reinstall uninstall install \
build build-api build-webui \
start-core start-dns start-api start-wg start-webui \
backup restore \
test test-all test-unit test-coverage test-api test-cli \
test-phase1 test-phase2 test-phase3 test-phase4 test-all-phases \
test-integration test-integration-readonly \
test-e2e-deps test-e2e-api test-e2e-ui test-e2e-wg test-e2e \
reset-test-admin-pass \
show-admin-password reset-admin-password \
show-routes add-peer list-peers \
ddns-update ddns-register
# Detect docker compose command (v2 plugin preferred, fallback to v1 standalone)
DC := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
# Full compose command: includes docker-compose.services.yml when it exists
DCF = $(DC) $(if $(wildcard docker-compose.services.yml),-f docker-compose.yml -f docker-compose.services.yml,-f docker-compose.yml)
# Default target
help:
@echo "Personal Internet Cell - Management Commands"
@echo ""
@echo "First install:"
@echo " check-deps - Install all required system packages (python3, docker, etc.)"
@echo " setup - Generate keys, write configs, create data dirs"
@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 "Lifecycle:"
@echo " start - Start all services"
@echo " stop - Stop all services"
@echo " restart - Restart all services"
@echo " status - Show container status + API health"
@echo " logs - Follow logs from all services"
@echo " logs-<svc> - Follow logs for one service (e.g. make logs-api)"
@echo " shell-<svc> - Open shell in a container (e.g. make shell-api)"
@echo ""
@echo "Updates & reinstall:"
@echo " update - git pull + rebuild + restart (deploy latest code)"
@echo " reinstall - Full wipe and fresh install from current git checkout"
@echo " uninstall - Stop + remove containers; prompts whether to also delete data"
@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 only"
@echo " start-api - Start API only"
@echo " start-wg - Start WireGuard only"
@echo ""
@echo "Maintenance:"
@echo " backup - Backup config + data to backups/"
@echo " restore - List available backups"
@echo " clean - Remove containers and volumes (keeps config/data dirs)"
@echo " show-admin-password - Print the admin password (reads setup file or prompts to reset)"
@echo " reset-admin-password - Generate a new random admin password and print it"
@echo ""
@echo "Tests:"
@echo " test - Run all tests"
@echo " test-coverage - Run tests with HTML coverage report"
@echo " test-integration - Full integration tests (needs running stack)"
@echo " test-integration-readonly - Read-only integration tests (safe to run anytime)"
@echo ""
@echo "Peers:"
@echo " list-peers - List configured WireGuard peers"
@echo " show-routes - Show WireGuard routing table"
# ── Dependencies & setup ──────────────────────────────────────────────────────
check-deps:
@sudo sh scripts/check_deps.sh
setup: check-deps
@echo "Setting up Personal Internet Cell..."
@sudo chown -R $${SUDO_USER:-$$(id -un)}:$${SUDO_USER:-$$(id -un)} config/ data/ 2>/dev/null || true
CELL_NAME=$(or $(CELL_NAME),mycell) \
CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \
DOMAIN_MODE=$(or $(DOMAIN_MODE),lan) \
CELL_DOMAIN_NAME=$(or $(CELL_DOMAIN_NAME),) \
CLOUDFLARE_API_TOKEN=$(or $(CLOUDFLARE_API_TOKEN),) \
DUCKDNS_TOKEN=$(or $(DUCKDNS_TOKEN),) \
DUCKDNS_SUBDOMAIN=$(or $(DUCKDNS_SUBDOMAIN),) \
VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \
WG_PORT=$(or $(WG_PORT),51820) \
WG_PRIVATE_KEY="$(WG_PRIVATE_KEY)" \
WG_PUBLIC_KEY="$(WG_PUBLIC_KEY)" \
python3 scripts/setup_cell.py
init-peers:
@echo "Initializing peer configuration..."
@echo '[]' > data/api/peers.json
@echo "Peer configuration initialized."
# ── Lifecycle ─────────────────────────────────────────────────────────────────
start:
@echo "Starting Personal Internet Cell..."
@docker network inspect cell-network >/dev/null 2>&1 || \
docker network create --driver bridge --subnet "$${CELL_NETWORK:-172.20.0.0/16}" cell-network
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build
@echo "Services started. Check status with 'make status'"
stop:
@echo "Stopping Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down
@echo "Services stopped."
restart:
@echo "Restarting Personal Internet Cell..."
PUID=$$(id -u) PGID=$$(id -g) $(DC) restart
@echo "Services restarted."
status:
@echo "Personal Internet Cell Status:"
@echo "================================"
$(DCF) ps
@echo ""
@echo "API Status:"
@curl -s http://localhost:3000/health || echo "API not responding"
logs:
$(DCF) logs -f
logs-%:
$(DCF) logs -f $*
shell-%:
docker exec -it cell-$* /bin/bash 2>/dev/null || docker exec -it cell-$* /bin/sh
# ── Updates & reinstall ───────────────────────────────────────────────────────
update:
@echo "Pulling latest code..."
@git config --global --add safe.directory $$(pwd) 2>/dev/null || true
@git stash --include-untracked --quiet 2>/dev/null || true
git pull
@git stash pop --quiet 2>/dev/null || true
@echo "Rebuilding and restarting services..."
@docker network inspect cell-network >/dev/null 2>&1 || \
docker network create --driver bridge --subnet "$${CELL_NETWORK:-172.20.0.0/16}" cell-network
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build
@echo "Update complete. Run 'make status' to verify."
reinstall:
@echo "Reinstalling Personal Internet Cell from scratch..."
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down 2>/dev/null || true
docker network rm cell-network 2>/dev/null || true
@sudo rm -rf config/ data/
@$(MAKE) setup
@$(MAKE) start
@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:
@echo ""
@echo "This will stop and remove all containers."
@echo ""
@printf "Also delete config/ and data/? This cannot be undone. [y/N/cancel]: "; \
read ans; \
case "$$ans" in \
y|Y) \
echo "Stopping containers and removing images..."; \
for f in data/api/services/*/docker-compose.yml; do [ -f "$$f" ] && PUID=$$(id -u) PGID=$$(id -g) docker compose -f "$$f" down 2>/dev/null || true; done; \
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down --rmi all 2>/dev/null || true; \
docker ps -aq --filter "name=cell-" | xargs -r docker rm -f 2>/dev/null || true; \
docker network rm cell-network 2>/dev/null || true; \
echo "Deleting config/ and data/..."; \
sudo rm -rf config/ data/; \
echo "Uninstall complete. Git repo and scripts remain."; \
;; \
n|N|"") \
echo "Stopping and removing containers (keeping images and data)..."; \
for f in data/api/services/*/docker-compose.yml; do [ -f "$$f" ] && PUID=$$(id -u) PGID=$$(id -g) docker compose -f "$$f" down 2>/dev/null || true; done; \
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core down 2>/dev/null || true; \
docker ps -aq --filter "name=cell-" | xargs -r docker rm -f 2>/dev/null || true; \
echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \
;; \
*) \
echo "Cancelled."; \
;; \
esac
@if command -v systemctl >/dev/null 2>&1; then \
sudo systemctl disable --now pic 2>/dev/null || true; \
sudo rm -f /etc/systemd/system/pic.service; \
sudo systemctl daemon-reload 2>/dev/null || true; \
fi
@-sudo rm -f /opt/pic/.installed
@echo "Note: Data volumes were not deleted. To remove all data, manually delete config/ and data/."
# ── Build ─────────────────────────────────────────────────────────────────────
build:
@echo "Building API service..."
$(DC) build api
build-api:
@echo "Rebuilding API (no cache)..."
$(DC) build --no-cache api
$(DC) up -d api
build-webui:
@echo "Rebuilding Web UI (no cache)..."
$(DC) build --no-cache webui
$(DC) up -d webui
# ── Individual services ───────────────────────────────────────────────────────
start-core:
@echo "Starting core services (caddy, dns, wireguard, api, webui)..."
@docker network inspect cell-network >/dev/null 2>&1 || \
docker network create --driver bridge --subnet "$${CELL_NETWORK:-172.20.0.0/16}" cell-network
PUID=$$(id -u) PGID=$$(id -g) $(DCF) --profile core up -d --build
@echo "Core services started. Run 'make start' to also bring up optional services."
start-dns:
$(DC) --profile core up -d dns
start-api:
$(DC) --profile core up -d api
start-wg:
$(DC) --profile core up -d wireguard
start-webui:
$(DC) --profile core up -d webui
# ── Maintenance ───────────────────────────────────────────────────────────────
clean:
@echo "Removing containers and volumes..."
$(DC) down -v
docker system prune -f
@echo "Done. config/ and data/ are untouched."
backup:
@echo "Creating backup..."
@mkdir -p backups
@sudo tar -czf backups/cell-backup-$(shell date +%Y%m%d-%H%M%S).tar.gz \
config/ data/ docker-compose.yml Makefile README.md
@sudo chown $$(id -u):$$(id -g) backups/cell-backup-*.tar.gz
@echo "Backup created in backups/."
@echo ""
@echo "WARNING: data volumes of installed store services (email, calendar,"
@echo "files, ...) are NOT included in this archive. They are only captured"
@echo "by API-driven backups (POST /api/config/backup), which dump each"
@echo "service's volumes via ConfigManager._backup_service_volumes."
restore:
@echo "Available backups:"
@ls -lh backups/cell-backup-*.tar.gz 2>/dev/null || echo "No backups found."
@echo ""
@echo "To restore: tar -xzf backups/cell-backup-YYYYMMDD-HHMMSS.tar.gz"
# ── Tests ─────────────────────────────────────────────────────────────────────
test:
@echo "Running unit tests..."
python3 -m pytest tests/ --ignore=tests/e2e --ignore=tests/integration -q
test-all: test test-integration test-e2e-api test-e2e-ui
@echo "All test suites complete."
test-unit:
python3 -m pytest tests/ --ignore=tests/e2e --ignore=tests/integration -q
test-coverage:
python3 -m pytest tests/ --ignore=tests/e2e --ignore=tests/integration \
--cov=api --cov-report=html --cov-report=term-missing --cov-fail-under=70 -v
test-integration:
@echo "Running full integration tests (requires running PIC stack)..."
PIC_HOST=$${PIC_HOST:-localhost} python3 -m pytest tests/integration/ -v
test-webui:
@echo "Running webui unit tests (requires node; builds a disposable container)..."
docker run --rm -v "$(PWD)/webui:/app" -w /app node:18-alpine \
sh -c "npm install --silent && npm test"
test-integration-readonly:
@echo "Running read-only integration tests (no peer creation)..."
PIC_HOST=$${PIC_HOST:-localhost} python3 -m pytest tests/integration/test_live_api.py tests/integration/test_webui.py -v
test-api:
python3 -m pytest tests/test_api_endpoints.py -v
test-cli:
python3 -m pytest tests/test_cli_tool.py -v
# ── E2E tests ─────────────────────────────────────────────────────────────────
# Run `make test-e2e-deps` once to install dependencies, then use the other targets.
# Admin password is read from data/api/.test_admin_pass (written by reset-test-admin-pass).
# Override: make test-e2e-api PIC_ADMIN_PASS=mypassword
test-e2e-deps:
sudo pip3 install --break-system-packages -r tests/e2e/requirements.txt
sudo python3 -m playwright install --with-deps chromium
test-e2e-api:
@PIC_HOST=$${PIC_HOST:-localhost} python3 -m pytest tests/e2e/api -v
test-e2e-ui:
@PIC_HOST=$${PIC_HOST:-localhost} python3 -m pytest tests/e2e/ui -v
test-e2e-wg:
@PIC_HOST=$${PIC_HOST:-localhost} sudo -E python3 -m pytest tests/e2e/wg -v -p no:xdist
test-e2e: test-e2e-api test-e2e-ui test-e2e-wg
reset-test-admin-pass:
ifndef PIC_TEST_ADMIN_PASS
$(error Usage: make reset-test-admin-pass PIC_TEST_ADMIN_PASS=newpassword)
endif
python3 scripts/reset_admin_password.py "$(PIC_TEST_ADMIN_PASS)"
# ── Admin password management ──────────────────────────────────────────────────
show-admin-password:
@sudo python3 scripts/reset_admin_password.py --show
reset-admin-password:
@docker exec cell-api python3 /app/scripts/reset_admin_password.py --generate
# ── Network / peers ───────────────────────────────────────────────────────────
show-routes:
@docker exec cell-wireguard wg show 2>/dev/null || echo "WireGuard not running"
list-peers:
@curl -s http://localhost:3000/api/peers | python3 -m json.tool || echo "API not responding"
add-peer:
@if [ -n "$(PEER_NAME)" ] && [ -n "$(PEER_IP)" ] && [ -n "$(PEER_KEY)" ]; then \
curl -X POST http://localhost:3000/api/peers \
-H "Content-Type: application/json" \
-d '{"name":"$(PEER_NAME)","ip":"$(PEER_IP)","public_key":"$(PEER_KEY)"}'; \
else \
echo "Usage: make add-peer PEER_NAME=name PEER_IP=10.0.0.x PEER_KEY=<pubkey>"; \
fi
# ── DDNS ─────────────────────────────────────────────────────────────────────
ddns-update:
@python3 scripts/ddns_update.py
ddns-register:
@DDNS_TOTP_SECRET="$(DDNS_TOTP_SECRET)" python3 -c "\
import os, sys; sys.path.insert(0, 'scripts'); \
from setup_cell import register_with_ddns, _read_existing_ip_range; \
import json; \
cfg = json.load(open('config/api/cell_config.json')) if os.path.exists('config/api/cell_config.json') else {}; \
name = cfg.get('_identity', {}).get('cell_name', os.environ.get('CELL_NAME', 'mycell')); \
import os; os.remove('data/api/.ddns_token') if os.path.exists('data/api/.ddns_token') else None; \
register_with_ddns(name)"
# ── Dev ───────────────────────────────────────────────────────────────────────
dev:
$(DC) -f docker-compose.yml -f docker-compose.dev.yml up -d