14 Commits

Author SHA1 Message Date
roof 925ab1f696 Overhaul setup wizard: domain config, password strength, field alignment
Unit Tests / test (push) Successful in 8m48s
Password:
- Add lowercase to strength scoring; "Good" now requires all API criteria
  (12 chars, upper, lower, digit) — no more submitting passwords the API rejects
- isReady gates the Next button on meeting API requirements, not just length

Domain steps 3 + 4:
- Step 3: choose pic_ngo / custom / lan (sends valid API domain_modes)
- Step 4 (pic.ngo): shows derived [cellName].pic.ngo domain preview
- Step 4 (custom): domain name field + TLS method selector
  (Cloudflare DNS-01 + API token, DuckDNS + token, HTTP-01 + port-80 warning)
- Step 4 skipped entirely for LAN-only
- Review step shows actual domain string and TLS method instead of opaque codes

Cell name:
- Description and preview hint make clear it becomes the pic.ngo subdomain
- Step 1 shows live "name.pic.ngo" preview as you type

Backend:
- setup_manager now accepts and stores domain_name, cloudflare_api_token,
  duckdns_token for Phase 3 DDNS registration use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:27:59 -04:00
roof 439886624e Fix config/data ownership — chown to invoking user after make install
Unit Tests / test (push) Successful in 8m46s
make install runs as root so all generated files (config/, data/) land
as root:root. Added a chown pass in install.sh after make install
completes, re-applying REPO_OWNER ownership. Also fixed the make setup
chown to use SUDO_USER when invoked via sudo rather than always id -u
(which is 0 when running as root).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 06:46:12 -04:00
roof 24877df976 Fix setup wizard and installer for fresh-install flow
Unit Tests / test (push) Successful in 8m53s
- setup_manager: fall back to update_password if admin already exists
  (installer bootstrap creates admin; wizard now updates rather than fails)
- install.sh: chown repo to SUDO_USER instead of pic user so the
  invoking operator can run make update without git safe.directory errors
- test: update mock to also stub update_password when testing total auth failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 06:08:55 -04:00
roof bfa0d99dd1 Fix git safe.directory error for non-root users after install
Unit Tests / test (push) Successful in 8m55s
The installer runs as root and chowns /opt/pic to the pic user.
Any other user (roof, operator) running make update then hits
"detected dubious ownership". Fix: add /opt/pic to system-wide
git safe.directory after clone, and add same guard in make update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:46:40 -04:00
roof 1e2cf5580f Fix setup wizard: align field names with API (domain_type→domain_mode, services→services_enabled)
Unit Tests / test (push) Successful in 8m52s
The wizard was sending domain_type and services but the API expected
domain_mode and services_enabled, causing a validation error on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:36:18 -04:00
roof 1989dfa0a3 Fix: exempt /api/setup/* from enforce_auth so setup wizard works on fresh install
Unit Tests / test (push) Successful in 8m49s
The setup wizard runs before any account exists, but the installer's
setup_cell.py creates auth_users.json with an admin account first.
This meant enforce_auth was active by the time the browser hit /setup,
blocking all /api/setup/* calls with 401. The CSRF hook already exempted
/api/setup/* — auth enforcement now matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:03:44 -04:00
roof 5dab6377bc Restore https:// now that git.pic.ngo has a TLS certificate
Unit Tests / test (push) Failing after 15m59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:33:51 -04:00
roof 0a24d20bbc Update QUICKSTART: use http for install.pic.ngo and git.pic.ngo (no HTTPS yet)
Unit Tests / test (push) Successful in 8m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 02:58:48 -04:00
roof 46599bd37e Fix installer: use http://git.pic.ngo without port (nginx forwards)
Unit Tests / test (push) Successful in 8m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 02:57:13 -04:00
roof dde4d9a53f Rewrite CLAUDE.md following article best practices
Unit Tests / test (push) Successful in 8m54s
Adds: tech stack, coding conventions, file placement rules, safety rules,
infrastructure topology table, and expands architecture with key-file table
and before-request hook documentation. Removes vague guidance, replaces
with actionable rules Claude can follow automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:25:53 -04:00
roof 674a66f7a0 Revert registry port: git.pic.ngo uses standard port (DNS fix pending)
Unit Tests / test (push) Successful in 8m55s
2026-05-10 06:59:13 -04:00
roof 9df3bf6a17 Fix release workflow: registry is git.pic.ngo:3000 not port 80
Unit Tests / test (push) Successful in 8m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 06:52:42 -04:00
roof 0773179962 Gitignore .coverage files
Unit Tests / test (push) Successful in 8m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 06:28:40 -04:00
roof 3a35cf72d3 Fix CI failures on root — mock OSError instead of relying on filesystem
Tests assumed write to /nonexistent/... fails, but CI runs as root where
Linux allows creating any path. Use unittest.mock.patch on builtins.open
with OSError side_effect so the test is environment-independent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 06:19:24 -04:00
16 changed files with 597 additions and 1223 deletions
+4
View File
@@ -87,3 +87,7 @@ backups/
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
# Coverage data
.coverage
htmlcov/
+252 -57
View File
@@ -1,87 +1,282 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file is the primary context source for Claude Code in this repository. Read it fully before touching any code.
## What This Project Is ---
**Personal Internet Cell (PIC)** — a self-hosted digital infrastructure platform. It manages DNS, DHCP, NTP, WireGuard VPN, email, calendar/contacts (CalDAV), file storage (WebDAV), reverse proxy (Caddy), a certificate authority, and container orchestration, all from a single API + React UI. ## Project Overview
## Common Commands **Personal Internet Cell (PIC)** is a self-hosted digital infrastructure platform for individuals who want full ownership of their core internet services without relying on cloud providers.
```bash A PIC instance runs DNS, DHCP, NTP, WireGuard VPN, email (SMTP/IMAP), calendar/contacts (CalDAV/CardDAV), file storage (WebDAV), HTTPS reverse proxy (Caddy), an internal certificate authority, and optional third-party services — all managed from a single REST API and a React web UI. No manual config-file editing is required for normal operations.
# Full stack
make start # docker-compose up -d
make stop # docker-compose down
make restart # docker-compose restart
make status # docker status + API health
make logs # docker-compose logs -f
make build # rebuild api image
# Tests **Primary users:** technically capable individuals, homelab operators, small families or teams.
make test # pytest tests/ api/tests/
make test-coverage # pytest with coverage HTML report
make test-api # pytest tests/test_api_endpoints.py
pytest tests/test_<module>.py # single test file
# Local dev (no Docker) **What the product optimizes for:**
pip install -r api/requirements.txt - One-command install, browser-based first-run wizard, no manual `.env` editing for identity
python api/app.py # Flask API on :3000 - Everything managed through the API and UI — the user should never need to `ssh` for day-to-day operations
- Security by default: session auth, CSRF protection, WireGuard isolation, internal CA, no open API port
- Reliability and observability: structured logs, health monitoring, automated config backups
cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000) **Key constraints:**
- Runs on a single Linux host with Docker; no Kubernetes, no swarm
- Must work on Debian, Ubuntu, Fedora, RHEL, and Alpine
- The Flask API must never be exposed directly; Caddy always proxies it
- All secrets live in `data/` (git-ignored), never in the repo
# WireGuard ---
make show-routes
make add-peer PEER_NAME=foo PEER_IP=10.0.0.5 PEER_KEY=<pubkey> ## Tech Stack
make list-peers
``` ### Backend
- **Python 3.11** — Flask REST API (`api/app.py`)
- **Flask** — routing, sessions, before-request hooks (enforce_setup, enforce_auth, check_csrf)
- **bcrypt** — password hashing in `AuthManager`
- **Docker SDK for Python** — container lifecycle in `ContainerManager`
- **PyNaCl / Age** — encryption in `VaultManager`
- **pyotp** — TOTP for DDNS registration
### Frontend
- **React 18** — SPA
- **Vite** — dev server and build (proxies `/api``:3000`)
- **Tailwind CSS** — all styling; no custom CSS files
- **Axios** — all API calls go through `src/services/api.js`
### Infrastructure
- **Docker Compose** — all 12+ service containers
- **Caddy** — reverse proxy, TLS termination (Let's Encrypt DNS-01 or HTTP-01 or internal CA)
- **CoreDNS** — `.cell` TLD authoritative DNS
- **dnsmasq** — DHCP
- **chrony** — NTP
- **WireGuard** — VPN (kernel module, not userspace)
- **Postfix + Dovecot** — email via `docker-mailserver`
- **Radicale** — CalDAV/CardDAV
- **PowerDNS** — authoritative DNS on the DDNS VPS (separate repo: `pic-ddns`)
### CI/CD
- **Gitea Actions** — unit tests on every push, image builds on tag
- **act_runner** — self-hosted runner on pic0 (192.168.31.51)
- **Gitea Container Registry** — images pushed to `git.pic.ngo`
Do not introduce: Redux, styled-components, SQLAlchemy, Celery, or any async framework (asyncio/FastAPI) into the main API unless explicitly requested.
---
## Architecture ## Architecture
### Backend (`api/`) ```
Browser / WireGuard peer
└── Caddy (:80/:443) TLS termination, reverse proxy
└── React SPA (:8081) Vite + Tailwind (Nginx in container)
└── Flask API (:3000) REST API, bound to 127.0.0.1 only
├── NetworkManager CoreDNS, dnsmasq, chrony
├── WireGuardManager WireGuard peer lifecycle
├── PeerRegistry peer registration and trust
├── EmailManager Postfix + Dovecot
├── CalendarManager Radicale CalDAV/CardDAV
├── FileManager WebDAV + Filegator
├── RoutingManager iptables NAT and routing
├── FirewallManager iptables INPUT/FORWARD rules
├── VaultManager internal CA, TLS certs, Age encryption
├── ContainerManager Docker SDK
├── CellLinkManager site-to-site WireGuard links
├── ConnectivityManager per-peer exit routing (WG ext, OpenVPN, Tor)
├── DDNSManager dynamic DNS heartbeat
├── ServiceStoreManager optional service install/remove
├── CaddyManager Caddyfile generation and reload
├── AuthManager bcrypt passwords, session auth, RBAC
└── SetupManager first-run wizard state
```
All service managers inherit `BaseServiceManager` (`api/base_service_manager.py`). This enforces a consistent interface: `get_status()`, `get_config()`, `update_config()`, `validate_config()`, `test_connectivity()`, `get_logs()`, `restart_service()`. When adding or modifying a service manager, follow this pattern. ### Key files
The `ServiceBus` (`api/service_bus.py`) is a pub/sub event system used for inter-service communication. Services publish events (e.g., `SERVICE_STARTED`, `CONFIG_CHANGED`, `PEER_CONNECTED`) and subscribe to events from dependencies. Dependency graph is declared in the bus — e.g., `wireguard` depends on `network`; `email` depends on `network` and `vault`. | File | Role |
|---|---|
| `api/app.py` | Flask app, all REST endpoints, before-request hooks, health monitor thread |
| `api/managers.py` | Singleton instantiation of all service managers |
| `api/base_service_manager.py` | Abstract base class: `get_status`, `get_config`, `update_config`, `validate_config`, `test_connectivity`, `get_logs`, `restart_service` |
| `api/config_manager.py` | Single source of truth for `cell_config.json` — all read/write goes through here |
| `api/service_bus.py` | Pub/sub event system between managers |
| `webui/src/services/api.js` | Axios API client — all UI→API calls |
| `docker-compose.yml` | Container definitions and network topology |
| `Makefile` | All operational commands |
| `install.sh` | Bash installer served via `https://install.pic.ngo` |
`ConfigManager` (`api/config_manager.py`) is the single source of truth. Config lives in `/app/config/cell_config.json` (mapped from `config/api/`). All managers read/write through ConfigManager, which validates against per-service schemas and maintains automatic backups. ### Directory layout
`LogManager` (`api/log_manager.py`) provides structured JSON logging with rotation (5 MB / 5 backups per service). Use it instead of `print()` or raw `logging`. ```
api/ Flask API and all service managers
webui/ React SPA (Vite + Tailwind)
tests/ pytest unit tests (no running services required)
tests/integration/ require a running PIC stack
tests/e2e/ Playwright UI and WireGuard e2e tests
config/ Runtime config per service (mostly git-ignored)
data/ Runtime secrets and state (fully git-ignored)
scripts/ Setup and maintenance scripts
install.sh One-line installer entry point
Makefile All make targets
docker-compose.yml
```
`app.py` (2000+ lines) contains all Flask REST endpoints, organized by service. It runs a background health-monitoring thread. ### Config and secrets
Service managers: - Runtime config: `config/api/cell_config.json` — managed by `ConfigManager`, never edit directly
- `network_manager.py` — DNS (CoreDNS), DHCP (dnsmasq), NTP (chrony) - Secrets and user data: `data/` — git-ignored, contains `auth_users.json`, WireGuard keys, DDNS token, CA key
- `wireguard_manager.py` — VPN peer lifecycle, QR codes - DDNS config lives under the top-level `ddns` key in `cell_config.json`, accessed via `config_manager.configs.get('ddns', {})`
- `peer_registry.py` — peer registration/lookup - Do not read `_identity.domain` expecting a dict — it is a plain string (the domain mode, e.g. `"pic_ngo"`)
- `routing_manager.py` — NAT, firewall rules, VPN gateway
- `vault_manager.py` — internal certificate authority
- `email_manager.py` — Postfix + Dovecot
- `calendar_manager.py` — Radicale CalDAV/CardDAV
- `file_manager.py` — WebDAV storage
- `container_manager.py` — Docker SDK wrappers
- `cell_manager.py` — top-level orchestration
### Frontend (`webui/`) ### Before-request hooks (app.py)
React 18 + Vite + Tailwind CSS. All API calls go through `src/services/api.js` (Axios). Vite dev server proxies `/api` to `localhost:3000`. Pages in `src/pages/`, shared components in `src/components/`. Three hooks run on every request in this order:
1. `enforce_setup` — returns 428 for all `/api/*` except `/api/setup/*` and `/health` until setup is complete. Skipped when `app.config['TESTING']` is True.
2. `enforce_auth` — returns 401 if no session; returns 503 if users file exists but is empty (misconfiguration). Skipped when `app.config['TESTING']` is True.
3. `check_csrf` — requires `X-CSRF-Token` header on all mutating requests except `/api/auth/*` and `/api/setup/*`.
### Infrastructure ---
`docker-compose.yml` defines 13 services on a custom bridge network `cell-network` (172.20.0.0/16). Cell IPs default to 10.0.0.0/24. Key ports: 53 (DNS), 80/443 (Caddy), 3000 (API), 5173/8081 (WebUI), 51820/udp (WireGuard), 25/587/993 (mail), 5232 (CalDAV), 8080 (WebDAV). ## Coding Conventions
Config files for each service live under `config/<service>/`. Persistent data is under `data/` (git-ignored). WireGuard configs are also git-ignored. ### Python (API)
## Testing - All managers inherit `BaseServiceManager` — always implement all abstract methods
- Use `self.logger` (from `BaseServiceManager`) — never `print()` or raw `logging`
- Config reads go through `self.config_manager` — never open `cell_config.json` directly
- Use `threading.RLock` for shared state; managers run in a multi-threaded Flask app
- Do not use `any` typing; be explicit
- Keep Flask route handlers thin — business logic belongs in the manager, not in `app.py`
- Error responses must be JSON: `jsonify({'error': '...'}), <status_code>`
- Do not catch bare `Exception` and silently swallow it — log at minimum
Tests live in `tests/` (28 files). Use mocking (`pytest-mock`) for external system calls. Integration tests in `test_integration.py` require Docker services running. ### JavaScript (webui)
## AI Collaboration Rules (Claude Code) - All API calls go through `src/services/api.js` — never use `fetch` or a new Axios instance directly
- Use functional components; no class components
- Tailwind utilities only — no inline styles, no custom CSS files
- Keep page components in `src/pages/`, reusable UI in `src/components/`
- State: local `useState`/`useEffect` is fine; no Redux or global state library
### General
- No comments that describe *what* the code does — only *why* if non-obvious
- No dead code, no commented-out blocks
- No backwards-compat shims for things being removed
- Prefer editing existing files over creating new ones
- Tests that write to disk: mock `builtins.open` with `OSError` rather than relying on `/nonexistent/path` (CI runs as root and can create any path)
---
## Testing and Quality
Before considering any task complete:
1. Run `make test` — all 1500+ unit tests must pass
2. Fix failures before committing — the pre-commit hook will block the commit anyway
### Rules
- Use `unittest.mock` / `pytest-mock` for all Docker, filesystem, and subprocess calls
- Tests must pass in CI (rootless environment where filesystem assumptions don't hold)
- When testing write-failure paths, mock `builtins.open` with `side_effect=OSError` — do not rely on unwritable paths
- Integration tests (`tests/integration/`) require a running stack — exclude from CI with `--ignore=tests/integration`
- E2e tests (`tests/e2e/`) require Playwright — exclude from CI with `--ignore=tests/e2e`
- Add tests for any new API endpoint, manager method, or utility function
- Do not add tests for Flask routing boilerplate or trivial getters — test behaviour, not structure
---
## File Placement Rules
| New thing | Where it goes |
|---|---|
| New service manager | `api/<name>_manager.py`, registered in `api/managers.py` and wired into `app.py` |
| New API endpoints | `app.py` — grouped with the relevant manager's existing endpoints |
| New React page | `webui/src/pages/` |
| Reusable UI component | `webui/src/components/` |
| New pytest test file | `tests/test_<module>.py` |
| Operational script | `scripts/` |
| Documentation | Update `README.md`, `QUICKSTART.md`, or `Personal Internet Cell Project Wiki.md` as appropriate |
Do not create a new abstraction for a single use case. Do not create near-duplicate files — edit the existing one.
---
## Safety Rules
- **Never expose the Flask API port (3000) directly** — it must always be behind Caddy
- **Never commit secrets** — `data/`, `.env`, `*.key`, `*.pem` are all git-ignored; keep it that way
- **Do not modify `enforce_setup` or `enforce_auth` hooks** without understanding the full auth flow — these are the security boundary
- **Do not change the `cell_config.json` schema** without updating `ConfigManager` validation and all manager reads
- **Do not rename API route paths** without checking the webui `api.js` client and any external callers
- **Do not modify WireGuard key generation** — losing the server private key means all peers must be re-provisioned
- Flag any change to auth flow, CSRF logic, or session management as security-sensitive before implementing
---
## Commands
```bash
# Stack lifecycle (always use make — never call docker/docker-compose directly)
make start # build and start all containers
make stop # stop all containers
make restart # restart containers
make status # container status + API health check
make logs # follow all container logs
make logs-api # follow API logs only
make logs-caddy # follow Caddy logs
make shell-api # shell inside the API container
make build-api # rebuild API image after code change
make build-webui # rebuild webui image after code change
# Tests
make test # pytest tests/ --ignore=tests/e2e --ignore=tests/integration
make test-coverage # coverage report in htmlcov/
pytest tests/test_<module>.py -v # single test file
# Local dev (no Docker)
pip install -r api/requirements.txt
python3 api/app.py # Flask API on :3000
cd webui && npm install && npm run dev # React UI on :5173 (proxies /api → :3000)
# Peer / WireGuard
make list-peers
make show-routes
# Admin password
make show-admin-password
make reset-admin-password
# Backup / restore
make backup
make restore
# Maintenance
make update # git pull + rebuild + restart
make uninstall # stop containers; prompt to delete config/ and data/
```
---
## Infrastructure Topology
| Machine | IP | Role |
|---|---|---|
| pic0 | 192.168.31.51 | Dev machine — you are here. Run all commands directly. |
| pic1 | 192.168.31.52 | Test/staging PIC instance |
| Gitea | 192.168.31.50 | Self-hosted git server (`gitea@192.168.31.50:roof/pic.git`) |
| DDNS VPS | 192.168.31.101 (LAN) / 178.168.15.65 (public) | PowerDNS + FastAPI for `*.pic.ngo` DDNS |
The `roof` user on pic0 has passwordless sudo and is in the `docker` group — use both freely.
---
## AI Collaboration Rules
These rules apply to every Claude Code session in this repo: These rules apply to every Claude Code session in this repo:
- **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` and referenced files at session start. - **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` at session start; follow referenced memory files for relevant context.
- **Dev machine context** — you are already on pic0 (192.168.31.51), the dev machine. Execute commands here directly; do not ask the user to run them. - **You are on pic0** — execute commands directly here; do not ask the user to run them.
- **Use all available agents** — spawn specialized sub-agents (pic-remote, pic-qa, pic-architect, etc.) for tasks that match their description. - **`make` is the only container interface** — never call `docker` or `docker-compose` directly. All container lifecycle goes through `make start`, `make stop`, `make build`, `make logs`, etc.
- **make is the only interface** — never call docker/docker-compose directly. All container lifecycle operations go through `make start`, `make stop`, `make build`, `make logs`, etc. - **Use specialized agents** — spawn `pic-remote` for VPS/pic1 SSH tasks, `pic-qa` for test writing, `pic-architect` for design decisions, `pic-designer` for UI review, `pic-devops` for docker-compose/Makefile changes, `pic-writer` for documentation.
- **Test every new feature** — after implementing any change, run `make test` before considering the task done. - **Test before commit** — run `make test` and fix all failures before staging. The pre-commit hook enforces this, but run it manually first.
- **Test before commit** — the pre-commit hook enforces this, but run `make test` manually first and fix all failures before staging files. - **No skipping hooks** — never use `--no-verify` unless the only change is documentation or a workflow file with no Python/JS.
- **Commits need context** — write commit messages that explain *why*, not just *what*. Always add the Co-Authored-By trailer.
+2 -1
View File
@@ -79,7 +79,7 @@ check-deps:
setup: check-deps setup: check-deps
@echo "Setting up Personal Internet Cell..." @echo "Setting up Personal Internet Cell..."
@sudo chown -R $$(id -u):$$(id -g) config/ data/ 2>/dev/null || true @sudo chown -R $${SUDO_USER:-$$(id -un)}:$${SUDO_USER:-$$(id -un)} config/ data/ 2>/dev/null || true
CELL_NAME=$(or $(CELL_NAME),mycell) \ CELL_NAME=$(or $(CELL_NAME),mycell) \
CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \ CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \
VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \ VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \
@@ -131,6 +131,7 @@ shell-%:
update: update:
@echo "Pulling latest code..." @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 stash --include-untracked --quiet 2>/dev/null || true
git pull git pull
@git stash pop --quiet 2>/dev/null || true @git stash pop --quiet 2>/dev/null || true
+2 -2
View File
@@ -199,8 +199,8 @@ def enforce_auth():
backward-compatibility with pre-auth test suites. backward-compatibility with pre-auth test suites.
""" """
path = request.path path = request.path
# Always allow non-API paths and auth namespace # Always allow non-API paths, auth namespace, and setup namespace
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
# Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session # Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session
if path.startswith('/api/cells/peer-sync/'): if path.startswith('/api/cells/peer-sync/'):
+18 -7
View File
@@ -128,9 +128,12 @@ class SetupManager:
cell_name = payload.get('cell_name', '') cell_name = payload.get('cell_name', '')
password = payload.get('password', '') password = payload.get('password', '')
domain_mode = payload.get('domain_mode', '') domain_mode = payload.get('domain_mode', '')
domain_name = payload.get('domain_name', '')
timezone = payload.get('timezone', '') timezone = payload.get('timezone', '')
services_enabled = payload.get('services_enabled', []) services_enabled = payload.get('services_enabled', [])
ddns_provider = payload.get('ddns_provider', 'none') ddns_provider = payload.get('ddns_provider', 'none')
cloudflare_api_token = payload.get('cloudflare_api_token', '')
duckdns_token = payload.get('duckdns_token', '')
errors.extend(self.validate_cell_name(cell_name)) errors.extend(self.validate_cell_name(cell_name))
errors.extend(self.validate_password(password)) errors.extend(self.validate_password(password))
@@ -168,28 +171,36 @@ class SetupManager:
if self.is_setup_complete(): if self.is_setup_complete():
return {'success': False, 'errors': ['Setup has already been completed.']} return {'success': False, 'errors': ['Setup has already been completed.']}
# ── create admin user ────────────────────────────────────────── # ── create or update admin user ────────────────────────────────
# The installer may have bootstrapped an admin account from a
# generated password. The wizard's job is to set the real password,
# so update it if the account already exists.
ok = self.auth_manager.create_user( ok = self.auth_manager.create_user(
username='admin', username='admin',
password=password, password=password,
role='admin', role='admin',
) )
if not ok: if not ok:
return {'success': False, 'errors': ['Failed to create admin user. The username may already exist.']} ok = self.auth_manager.update_password('admin', password)
if not ok:
return {'success': False, 'errors': ['Failed to set admin password.']}
# ── persist identity fields ──────────────────────────────────── # ── persist identity fields ────────────────────────────────────
self.config_manager.set_identity_field('cell_name', cell_name) 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('domain_mode', domain_mode)
if domain_name:
self.config_manager.set_identity_field('domain_name', domain_name)
self.config_manager.set_identity_field('timezone', timezone) self.config_manager.set_identity_field('timezone', timezone)
self.config_manager.set_identity_field('services_enabled', services_enabled) self.config_manager.set_identity_field('services_enabled', services_enabled)
self.config_manager.set_identity_field('ddns_provider', ddns_provider) self.config_manager.set_identity_field('ddns_provider', ddns_provider)
if cloudflare_api_token:
self.config_manager.set_identity_field('cloudflare_api_token', cloudflare_api_token)
if duckdns_token:
self.config_manager.set_identity_field('duckdns_token', duckdns_token)
# NOTE: DDNS registration is deferred to Phase 3.
# For now we just store ddns_provider in config.
logger.info( logger.info(
'DDNS registration skipped (Phase 1). ' 'DDNS registration deferred to Phase 3. '
'DDNS registration will happen in Phase 3. ' f'ddns_provider={ddns_provider!r} domain_name={domain_name!r}'
f'ddns_provider={ddns_provider!r} stored in identity config.'
) )
# ── mark setup complete (must be last) ───────────────────────── # ── mark setup complete (must be last) ─────────────────────────
-36
View File
@@ -1,36 +0,0 @@
{
"cell_name": "modified",
"domain": "cell.local",
"ip_range": "10.0.0.0/24",
"network": {
"dns_port": 53,
"dhcp_range": "10.0.0.100-10.0.0.200",
"ntp_servers": ["pool.ntp.org"]
},
"wireguard": {
"port": 51820,
"private_key": "test_key",
"address": "10.0.0.1/24"
},
"email": {
"domain": "cell.local",
"smtp_port": 25,
"imap_port": 143
},
"calendar": {
"port": 5232,
"data_dir": "/app/data/calendar"
},
"files": {
"port": 8080,
"data_dir": "/app/data/files"
},
"routing": {
"nat_enabled": true,
"firewall_enabled": true
},
"vault": {
"ca_configured": true,
"fernet_configured": true
}
}
-389
View File
@@ -1,389 +0,0 @@
# Personal Internet Cell - Network Configuration Guide
This guide explains how to configure networking for the Personal Internet Cell to provide internet access to WireGuard VPN clients.
## Table of Contents
1. [Overview](#overview)
2. [Network Architecture](#network-architecture)
3. [Quick Setup](#quick-setup)
4. [Detailed Configuration](#detailed-configuration)
5. [Troubleshooting](#troubleshooting)
6. [Advanced Configuration](#advanced-configuration)
7. [Security Considerations](#security-considerations)
## Overview
The Personal Internet Cell provides a complete VPN solution with internet access. This requires proper configuration of:
- **IP Forwarding**: Allow traffic to pass through the server
- **NAT (Network Address Translation)**: Translate private IPs to public IPs
- **Routing**: Direct traffic from VPN clients to the internet
- **Firewall Rules**: Control traffic flow and security
## Network Architecture
```
Internet
[Host Server] (195.178.106.244)
├── [Docker Network] (172.20.0.0/16)
│ └── [WireGuard Container] (cell-wireguard)
│ └── [WireGuard Interface] (wg0: 10.0.0.1/24)
└── [VPN Clients] (10.0.0.2-10.0.0.254/24)
└── [Internet Access via NAT]
```
### Key Components
- **Host Interface**: `eth0` (or main network interface)
- **WireGuard Interface**: `wg0` (10.0.0.1/24)
- **Client Network**: `10.0.0.0/24`
- **NAT Translation**: Client IPs → Host IP
## Quick Setup
### 1. Run the Network Configuration Script
```bash
# Make the script executable (if not already done)
chmod +x /opt/pic/scripts/setup-network.sh
# Run the configuration
sudo /opt/pic/scripts/setup-network.sh setup
```
### 2. Verify Configuration
```bash
# Check status
sudo /opt/pic/scripts/setup-network.sh status
# Test configuration
sudo /opt/pic/scripts/setup-network.sh test
```
### 3. Connect a VPN Client
Use the generated WireGuard configuration to connect a client. The client should now have internet access.
## Detailed Configuration
### IP Forwarding
IP forwarding allows the server to route packets between different network interfaces.
**Enable on Host:**
```bash
echo 'net.ipv4.ip_forward = 1' >> /etc/sysctl.conf
sysctl -p
```
**Enable in Container:**
```bash
docker exec cell-wireguard sh -c "echo 1 > /proc/sys/net/ipv4/ip_forward"
```
### NAT Configuration
NAT (Network Address Translation) allows VPN clients to access the internet using the server's public IP.
**Container NAT Rules:**
```bash
# Allow forwarding for WireGuard traffic
iptables -A FORWARD -i wg0 -j ACCEPT
iptables -A FORWARD -o wg0 -j ACCEPT
# NAT rule for internet access
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
```
**Host NAT Rules:**
```bash
# Allow traffic from WireGuard network
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i wg0 -j ACCEPT
iptables -A FORWARD -o wg0 -j ACCEPT
```
### Routing Configuration
**WireGuard Interface Setup:**
```bash
# Create WireGuard interface
ip link add dev wg0 type wireguard
# Set private key
wg set wg0 private-key /path/to/private-key
# Set listen port
wg set wg0 listen-port 51820
# Add IP address
ip addr add 10.0.0.1/24 dev wg0
# Bring interface up
ip link set wg0 up
# Add peers
wg set wg0 peer <public-key> allowed-ips 10.0.0.2/32
```
## Troubleshooting
### Common Issues
#### 1. VPN Connected but No Internet
**Symptoms:**
- WireGuard shows connected
- Can ping server (10.0.0.1)
- Cannot access internet
**Solutions:**
```bash
# Check IP forwarding
cat /proc/sys/net/ipv4/ip_forward
# Should return 1
# Check NAT rules
iptables -t nat -L POSTROUTING -n
# Should show MASQUERADE rule for 10.0.0.0/24
# Check forwarding rules
iptables -L FORWARD -n
# Should show ACCEPT rules for wg0
# Restart network configuration
sudo /opt/pic/scripts/setup-network.sh reset
sudo /opt/pic/scripts/setup-network.sh setup
```
#### 2. Cannot Connect to VPN
**Symptoms:**
- WireGuard client cannot connect
- No handshake in server logs
**Solutions:**
```bash
# Check WireGuard interface
docker exec cell-wireguard wg show
# Check if port 51820 is open
netstat -ulnp | grep 51820
# Check firewall rules
ufw status
iptables -L INPUT -n
# Check Docker port mapping
docker port cell-wireguard
```
#### 3. DNS Issues
**Symptoms:**
- Can ping IP addresses
- Cannot resolve domain names
**Solutions:**
```bash
# Check DNS configuration in client config
# Should include: DNS = 8.8.8.8, 1.1.1.1
# Test DNS from container
docker exec cell-wireguard nslookup google.com
# Check if DNS is being blocked
docker exec cell-wireguard iptables -L -n | grep 53
```
### Diagnostic Commands
```bash
# Check network status
sudo /opt/pic/scripts/setup-network.sh status
# Test connectivity from container
docker exec cell-wireguard ping -c 3 8.8.8.8
# Check routing table
docker exec cell-wireguard ip route show
# Check interface status
docker exec cell-wireguard ip addr show wg0
# Check NAT rules
docker exec cell-wireguard iptables -t nat -L -n
# Check forwarding rules
docker exec cell-wireguard iptables -L FORWARD -n
```
## Advanced Configuration
### Custom DNS Servers
To use custom DNS servers, modify the WireGuard client configuration:
```ini
[Interface]
PrivateKey = <private-key>
Address = 10.0.0.2/32
DNS = 1.1.1.1, 1.0.0.1, 8.8.8.8, 8.8.4.4
[Peer]
PublicKey = <server-public-key>
Endpoint = 195.178.106.244:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
```
### Split Tunneling
To allow only specific traffic through the VPN:
```ini
[Peer]
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
# Only route private networks through VPN
```
### Port Forwarding
To forward specific ports to VPN clients:
```bash
# Forward port 8080 to client 10.0.0.2
iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.2:8080
iptables -A FORWARD -p tcp -d 10.0.0.2 --dport 8080 -j ACCEPT
```
### Bandwidth Limiting
To limit bandwidth for VPN clients:
```bash
# Install tc (traffic control)
apt-get install iproute2
# Limit client 10.0.0.2 to 1Mbps
tc qdisc add dev wg0 root handle 1: htb default 30
tc class add dev wg0 parent 1: classid 1:1 htb rate 1mbit
tc class add dev wg0 parent 1:1 classid 1:10 htb rate 1mbit ceil 1mbit
tc filter add dev wg0 protocol ip parent 1:0 prio 1 u32 match ip dst 10.0.0.2 flowid 1:10
```
## Security Considerations
### Firewall Rules
**Basic Security Rules:**
```bash
# Drop invalid packets
iptables -A INPUT -m conntrack --ctstate INVALID -j DROP
# Allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow WireGuard traffic
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
# Allow SSH (if needed)
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
# Drop everything else
iptables -A INPUT -j DROP
```
### Client Isolation
To prevent clients from communicating with each other:
```bash
# Block inter-client communication
iptables -A FORWARD -i wg0 -o wg0 -j DROP
```
### Logging
To log VPN traffic:
```bash
# Log all WireGuard traffic
iptables -A FORWARD -i wg0 -j LOG --log-prefix "WG-FORWARD: "
iptables -A FORWARD -o wg0 -j LOG --log-prefix "WG-FORWARD: "
# Log NAT traffic
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -j LOG --log-prefix "WG-NAT: "
```
## Monitoring
### Real-time Monitoring
```bash
# Monitor WireGuard connections
watch -n 1 "docker exec cell-wireguard wg show"
# Monitor traffic
watch -n 1 "docker exec cell-wireguard wg show wg0 transfer"
# Monitor NAT rules
watch -n 1 "iptables -t nat -L POSTROUTING -n -v"
```
### Log Analysis
```bash
# Check system logs
journalctl -u pic-network.service -f
# Check iptables logs
tail -f /var/log/kern.log | grep WG-
# Check Docker logs
docker logs cell-wireguard -f
```
## Backup and Recovery
### Backup Configuration
```bash
# Backup iptables rules
iptables-save > /opt/pic/backups/iptables-backup-$(date +%Y%m%d).rules
# Backup WireGuard configuration
cp /opt/pic/config/wireguard/wg_confs/wg0.conf /opt/pic/backups/wg0-backup-$(date +%Y%m%d).conf
# Backup network script
cp /opt/pic/scripts/setup-network.sh /opt/pic/backups/setup-network-backup-$(date +%Y%m%d).sh
```
### Restore Configuration
```bash
# Restore iptables rules
iptables-restore < /opt/pic/backups/iptables-backup-YYYYMMDD.rules
# Restore WireGuard configuration
cp /opt/pic/backups/wg0-backup-YYYYMMDD.conf /opt/pic/config/wireguard/wg_confs/wg0.conf
docker restart cell-wireguard
```
## Support
If you encounter issues:
1. Check the troubleshooting section above
2. Run the diagnostic commands
3. Check the logs for error messages
4. Verify your network configuration
5. Test with a simple client configuration
For additional help, check the main Personal Internet Cell documentation or create an issue in the project repository.
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env python3
"""
Script to fix import statements in test files
"""
import os
import re
from pathlib import Path
def fix_imports_in_file(file_path):
"""Fix import statements in a test file"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Fix relative imports to absolute imports from api package
content = re.sub(r'from \.(\w+) import', r'from \1 import', content)
content = re.sub(r'import \.(\w+)', r'import \1', content)
# Add path setup if not present
if 'sys.path.insert' not in content and 'api_dir' not in content:
path_setup = '''import sys
from pathlib import Path
# Add api directory to path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
'''
# Insert after the first import line
lines = content.split('\n')
for i, line in enumerate(lines):
if line.startswith('import ') or line.startswith('from '):
lines.insert(i, path_setup.rstrip())
break
content = '\n'.join(lines)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Fixed imports in {file_path}")
def main():
"""Fix all test files"""
tests_dir = Path('tests')
for test_file in tests_dir.glob('test_*.py'):
if test_file.name not in ['test_cli_tool.py', 'test_peer_registry.py']: # Already fixed
fix_imports_in_file(test_file)
if __name__ == '__main__':
main()
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""
Fix import statements in test files
"""
import os
import re
from pathlib import Path
def fix_imports_in_file(file_path):
"""Fix import statements in a test file"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Replace 'from api.' with 'from .'
content = re.sub(r'from api\.', 'from .', content)
content = re.sub(r'import api\.', 'import .', content)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Fixed imports in {file_path}")
def main():
tests_dir = Path('tests')
for test_file in tests_dir.glob('test_*.py'):
fix_imports_in_file(test_file)
if __name__ == '__main__':
main()
+10 -5
View File
@@ -245,23 +245,28 @@ else
log_ok "Repository cloned to ${PIC_DIR}" log_ok "Repository cloned to ${PIC_DIR}"
fi fi
# Ensure the pic user owns the directory # Give the invoking user (or pic if run directly as root) ownership of the repo
chown -R "${PIC_USER}:${PIC_USER}" "$PIC_DIR" # so they can run `make update` and other git commands without sudo.
REPO_OWNER="${SUDO_USER:-${PIC_USER}}"
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
# Allow all users to run git commands here regardless of who owns the files
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 5 — Run make install # Step 5 — Run make install
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 5 "Running '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" cd "$PIC_DIR"
if ! make install 2>&1 | sed 's/^/ /'; then if ! make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above." die "'make install' failed. Check the output above."
fi fi
# make install runs as root so config/ and data/ get created root-owned.
# Re-apply ownership to the invoking user so they can manage files without sudo.
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
log_ok "'make install' complete" log_ok "'make install' complete"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-559
View File
@@ -1,559 +0,0 @@
#!/usr/bin/env python3
"""
Comprehensive tests for Flask app endpoints
"""
import unittest
import sys
import os
import tempfile
import shutil
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
# Add api directory to path
api_dir = Path(__file__).parent / 'api'
sys.path.insert(0, str(api_dir))
class TestFlaskAppEndpoints(unittest.TestCase):
def setUp(self):
"""Set up test environment"""
# Create temporary directories
self.test_dir = tempfile.mkdtemp()
self.data_dir = os.path.join(self.test_dir, 'data')
self.config_dir = os.path.join(self.test_dir, 'config')
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.config_dir, exist_ok=True)
# Set environment variables
os.environ['TESTING'] = 'true'
os.environ['LOG_LEVEL'] = 'ERROR'
# Import and create app
from app import app
self.app = app
self.client = app.test_client()
# Mock external dependencies
self.patchers = []
# Mock subprocess.run
subprocess_patcher = patch('subprocess.run')
self.mock_subprocess = subprocess_patcher.start()
self.mock_subprocess.return_value.returncode = 0
self.mock_subprocess.return_value.stdout = b"test output"
self.patchers.append(subprocess_patcher)
# Mock docker
docker_patcher = patch('docker.from_env')
self.mock_docker = docker_patcher.start()
self.mock_docker_client = MagicMock()
self.mock_docker.return_value = self.mock_docker_client
self.patchers.append(docker_patcher)
# Mock file operations
file_patcher = patch('builtins.open', create=True)
self.mock_file = file_patcher.start()
self.mock_file.return_value.__enter__.return_value.read.return_value = '{}'
self.patchers.append(file_patcher)
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.test_dir)
for patcher in self.patchers:
patcher.stop()
def test_health_endpoint(self):
"""Test /health endpoint"""
response = self.client.get('/health')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_status_endpoint(self):
"""Test /api/status endpoint"""
response = self.client.get('/api/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_config_get_endpoint(self):
"""Test GET /api/config endpoint"""
response = self.client.get('/api/config')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, dict)
def test_api_config_put_endpoint(self):
"""Test PUT /api/config endpoint"""
test_config = {'test': 'value'}
response = self.client.put('/api/config',
data=json.dumps(test_config),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_config_backup_endpoint(self):
"""Test POST /api/config/backup endpoint"""
response = self.client.post('/api/config/backup')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('backup_id', data)
def test_api_config_backups_endpoint(self):
"""Test GET /api/config/backups endpoint"""
response = self.client.get('/api/config/backups')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_config_restore_endpoint(self):
"""Test POST /api/config/restore/<backup_id> endpoint"""
response = self.client.post('/api/config/restore/test_backup')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_config_export_endpoint(self):
"""Test GET /api/config/export endpoint"""
response = self.client.get('/api/config/export')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, dict)
def test_api_config_import_endpoint(self):
"""Test POST /api/config/import endpoint"""
test_config = {'test': 'value'}
response = self.client.post('/api/config/import',
data=json.dumps(test_config),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_services_bus_status_endpoint(self):
"""Test GET /api/services/bus/status endpoint"""
response = self.client.get('/api/services/bus/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('services', data)
def test_api_services_bus_events_endpoint(self):
"""Test GET /api/services/bus/events endpoint"""
response = self.client.get('/api/services/bus/events')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_services_bus_start_endpoint(self):
"""Test POST /api/services/bus/services/<service_name>/start endpoint"""
response = self.client.post('/api/services/bus/services/test/start')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_services_bus_stop_endpoint(self):
"""Test POST /api/services/bus/services/<service_name>/stop endpoint"""
response = self.client.post('/api/services/bus/services/test/stop')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_services_bus_restart_endpoint(self):
"""Test POST /api/services/bus/services/<service_name>/restart endpoint"""
response = self.client.post('/api/services/bus/services/test/restart')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_logs_services_endpoint(self):
"""Test GET /api/logs/services/<service> endpoint"""
response = self.client.get('/api/logs/services/test')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_logs_search_endpoint(self):
"""Test POST /api/logs/search endpoint"""
search_data = {'query': 'test', 'level': 'INFO'}
response = self.client.post('/api/logs/search',
data=json.dumps(search_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_logs_export_endpoint(self):
"""Test POST /api/logs/export endpoint"""
export_data = {'format': 'json', 'filters': {}}
response = self.client.post('/api/logs/export',
data=json.dumps(export_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('export_path', data)
def test_api_logs_statistics_endpoint(self):
"""Test GET /api/logs/statistics endpoint"""
response = self.client.get('/api/logs/statistics')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('total_entries', data)
def test_api_logs_rotate_endpoint(self):
"""Test POST /api/logs/rotate endpoint"""
response = self.client.post('/api/logs/rotate')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_dns_records_endpoints(self):
"""Test DNS records endpoints"""
# GET
response = self.client.get('/api/dns/records')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST
record_data = {'name': 'test.example.com', 'type': 'A', 'value': '192.168.1.1'}
response = self.client.post('/api/dns/records',
data=json.dumps(record_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE
response = self.client.delete('/api/dns/records',
data=json.dumps(record_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_dhcp_endpoints(self):
"""Test DHCP endpoints"""
# GET leases
response = self.client.get('/api/dhcp/leases')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST reservation
reservation_data = {'mac': '00:11:22:33:44:55', 'ip': '192.168.1.100'}
response = self.client.post('/api/dhcp/reservations',
data=json.dumps(reservation_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE reservation
response = self.client.delete('/api/dhcp/reservations',
data=json.dumps(reservation_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_ntp_status_endpoint(self):
"""Test GET /api/ntp/status endpoint"""
response = self.client.get('/api/ntp/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_network_info_endpoint(self):
"""Test GET /api/network/info endpoint"""
response = self.client.get('/api/network/info')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('interfaces', data)
def test_api_dns_status_endpoint(self):
"""Test GET /api/dns/status endpoint"""
response = self.client.get('/api/dns/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_network_test_endpoint(self):
"""Test POST /api/network/test endpoint"""
test_data = {'target': '8.8.8.8', 'type': 'ping'}
response = self.client.post('/api/network/test',
data=json.dumps(test_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_wireguard_endpoints(self):
"""Test WireGuard endpoints"""
# GET keys
response = self.client.get('/api/wireguard/keys')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('public_key', data)
# POST generate peer keys
response = self.client.post('/api/wireguard/keys/peer')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('public_key', data)
# GET config
response = self.client.get('/api/wireguard/config')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('config', data)
# GET peers
response = self.client.get('/api/wireguard/peers')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST add peer
peer_data = {'peer': 'test_peer', 'ip': '10.0.0.1', 'public_key': 'test_key'}
response = self.client.post('/api/wireguard/peers',
data=json.dumps(peer_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE remove peer
response = self.client.delete('/api/wireguard/peers',
data=json.dumps({'peer': 'test_peer'}),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# GET status
response = self.client.get('/api/wireguard/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_peers_endpoints(self):
"""Test peers endpoints"""
# GET peers
response = self.client.get('/api/peers')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST add peer
peer_data = {'peer': 'test_peer', 'ip': '10.0.0.1'}
response = self.client.post('/api/peers',
data=json.dumps(peer_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE remove peer
response = self.client.delete('/api/peers/test_peer')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
def test_api_email_endpoints(self):
"""Test email endpoints"""
# GET users
response = self.client.get('/api/email/users')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST create user
user_data = {'username': 'test_user', 'email': 'test@example.com'}
response = self.client.post('/api/email/users',
data=json.dumps(user_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE user
response = self.client.delete('/api/email/users/test_user')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# GET status
response = self.client.get('/api/email/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_calendar_endpoints(self):
"""Test calendar endpoints"""
# GET users
response = self.client.get('/api/calendar/users')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST create user
user_data = {'username': 'test_user', 'email': 'test@example.com'}
response = self.client.post('/api/calendar/users',
data=json.dumps(user_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE user
response = self.client.delete('/api/calendar/users/test_user')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# GET status
response = self.client.get('/api/calendar/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_files_endpoints(self):
"""Test files endpoints"""
# GET users
response = self.client.get('/api/files/users')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST create user
user_data = {'username': 'test_user'}
response = self.client.post('/api/files/users',
data=json.dumps(user_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# DELETE user
response = self.client.delete('/api/files/users/test_user')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# GET status
response = self.client.get('/api/files/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
def test_api_routing_endpoints(self):
"""Test routing endpoints"""
# GET status
response = self.client.get('/api/routing/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
# POST NAT rule
nat_data = {'type': 'masquerade', 'interface': 'eth0'}
response = self.client.post('/api/routing/nat',
data=json.dumps(nat_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('rule_id', data)
# GET NAT rules
response = self.client.get('/api/routing/nat')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_vault_endpoints(self):
"""Test vault endpoints"""
# GET status
response = self.client.get('/api/vault/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('status', data)
# GET certificates
response = self.client.get('/api/vault/certificates')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST generate certificate
cert_data = {'common_name': 'test.example.com'}
response = self.client.post('/api/vault/certificates',
data=json.dumps(cert_data),
content_type='application/json')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('certificate', data)
# GET CA certificate
response = self.client.get('/api/vault/ca/certificate')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('certificate', data)
def test_api_containers_endpoints(self):
"""Test containers endpoints"""
# GET containers
response = self.client.get('/api/containers')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
# POST start container
response = self.client.post('/api/containers/test/start')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# POST stop container
response = self.client.post('/api/containers/test/stop')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('success', data)
# GET container logs
response = self.client.get('/api/containers/test/logs')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_services_status_endpoint(self):
"""Test GET /api/services/status endpoint"""
response = self.client.get('/api/services/status')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('services', data)
def test_api_services_connectivity_endpoint(self):
"""Test GET /api/services/connectivity endpoint"""
response = self.client.get('/api/services/connectivity')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIn('results', data)
def test_api_health_history_endpoint(self):
"""Test GET /api/health/history endpoint"""
response = self.client.get('/api/health/history')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
def test_api_logs_endpoint(self):
"""Test GET /api/logs endpoint"""
response = self.client.get('/api/logs')
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertIsInstance(data, list)
if __name__ == '__main__':
unittest.main()
+2 -1
View File
@@ -133,7 +133,8 @@ class TestGenerateCorefile(unittest.TestCase):
self.assertIn('reload', content) self.assertIn('reload', content)
def test_returns_false_on_write_error(self): def test_returns_false_on_write_error(self):
result = firewall_manager.generate_corefile([], '/nonexistent/path/Corefile') with unittest.mock.patch('builtins.open', side_effect=OSError('Permission denied')):
result = firewall_manager.generate_corefile([], '/any/path/Corefile')
self.assertFalse(result) self.assertFalse(result)
+3 -1
View File
@@ -5,6 +5,7 @@ import sys
import os import os
import tempfile import tempfile
import unittest import unittest
import unittest.mock
from pathlib import Path from pathlib import Path
api_dir = Path(__file__).parent.parent / 'api' api_dir = Path(__file__).parent.parent / 'api'
@@ -98,7 +99,8 @@ class TestWriteEnvFile(unittest.TestCase):
self.assertTrue(result) self.assertTrue(result)
def test_returns_false_on_unwritable_path(self): def test_returns_false_on_unwritable_path(self):
result = ip_utils.write_env_file('172.20.0.0/16', '/nonexistent/deep/path/.env') with unittest.mock.patch('builtins.open', side_effect=OSError('Permission denied')):
result = ip_utils.write_env_file('172.20.0.0/16', '/any/path/.env')
self.assertFalse(result) self.assertFalse(result)
def test_contains_cell_network(self): def test_contains_cell_network(self):
+6
View File
@@ -128,6 +128,12 @@ def test_anon_blocked_from_peer_routes(anon_client):
assert r.status_code == 401 assert r.status_code == 401
def test_setup_routes_bypass_auth(anon_client):
"""/api/setup/* must be reachable without a session — setup runs before any account exists."""
r = anon_client.get('/api/setup/status')
assert r.status_code != 401
def test_anon_blocked_from_peer_dashboard(anon_client): def test_anon_blocked_from_peer_dashboard(anon_client):
r = anon_client.get('/api/peer/dashboard') r = anon_client.get('/api/peer/dashboard')
assert r.status_code == 401 assert r.status_code == 401
+2 -1
View File
@@ -252,10 +252,11 @@ def test_complete_setup_returns_error_when_create_user_fails(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path): setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {} mock_config_manager.get_identity.return_value = {}
mock_auth_manager.create_user.return_value = False mock_auth_manager.create_user.return_value = False
mock_auth_manager.update_password.return_value = False
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload()) result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False assert result['success'] is False
assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors']) assert any('admin' in e.lower() or 'password' in e.lower() for e in result['errors'])
# ── get_setup_status ────────────────────────────────────────────────────────── # ── get_setup_status ──────────────────────────────────────────────────────────
+285 -71
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react'; import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
import { setupAPI } from '../services/api'; import { setupAPI } from '../services/api';
// constants // constants
@@ -13,27 +13,36 @@ const DOMAIN_OPTIONS = [
{ {
value: 'pic_ngo', value: 'pic_ngo',
label: 'PIC.NGO subdomain', label: 'PIC.NGO subdomain',
description: 'Get a free yourname.pic.ngo domain — managed automatically.', description: 'Get a free yourname.pic.ngo address — HTTPS and DDNS managed automatically.',
}, },
{ {
value: 'custom', value: 'custom',
label: 'Custom domain', label: 'Custom domain',
description: 'Bring your own domain. You will configure DNS records manually.', description: 'Use your own domain with Cloudflare, DuckDNS, or standard HTTP challenge.',
}, },
{ {
value: 'lan_only', value: 'lan',
label: 'LAN only', label: 'LAN only',
description: 'No public domain. Accessible only on your local network and via VPN.', description: 'No public domain. Accessible only on your local network and via VPN.',
}, },
]; ];
const DDNS_OPTIONS = [ const CUSTOM_METHOD_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: 'cloudflare',
{ value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' }, label: 'Cloudflare DNS',
{ value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' }, description: 'DNS-01 via Cloudflare API. Your domain must use Cloudflare nameservers.',
{ value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' }, },
{ value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' }, {
value: 'duckdns',
label: 'DuckDNS',
description: 'Free subdomain via duckdns.org with automatic DNS-01 challenge.',
},
{
value: 'http01',
label: 'HTTP-01 (any registrar)',
description: 'Standard ACME challenge. Port 80 must be publicly reachable.',
},
]; ];
const OPTIONAL_SERVICES = [ const OPTIONAL_SERVICES = [
@@ -55,35 +64,37 @@ function getAllTimezones() {
try { try {
return Intl.supportedValuesOf('timeZone'); return Intl.supportedValuesOf('timeZone');
} catch { } catch {
// Fallback list for older browsers
return [ return [
'UTC', 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
'America/New_York', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
'America/Chicago', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Australia/Sydney',
]; ];
} }
} }
function passwordStrength(pw) { function passwordStrength(pw) {
if (!pw) return { label: '', color: '', width: '0%' }; if (!pw) return { label: '', color: '', width: '0%', score: 0 };
let score = 0; let score = 0;
if (pw.length >= 12) score++; if (pw.length >= 12) score++;
if (pw.length >= 16) score++; if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++; if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-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: 'Weak', color: 'bg-red-500', width: '20%', score };
if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' }; if (score === 3) return { label: 'Fair', color: 'bg-yellow-500', width: '45%', score };
if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' }; if (score === 4) return { label: 'Good', color: 'bg-blue-500', width: '70%', score };
return { label: 'Strong', color: 'bg-green-500', width: '100%' }; return { label: 'Strong', color: 'bg-green-500', width: '100%', score };
}
function meetsApiRequirements(pw) {
return pw.length >= 12 && /[A-Z]/.test(pw) && /[a-z]/.test(pw) && /[0-9]/.test(pw);
}
function getDomainMode(domainType, customMethod) {
if (domainType === 'pic_ngo') return 'pic_ngo';
if (domainType === 'lan') return 'lan';
return customMethod || 'http01';
} }
// sub-components // sub-components
@@ -218,12 +229,14 @@ function Step1CellName({ value, onChange, onNext }) {
} }
}; };
const isValid = CELL_NAME_RE.test(value);
return ( return (
<div> <div>
<StepHeader <StepHeader
step={1} step={1}
title="Name your cell" title="Name your cell"
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs." description="This becomes your cell's identity and subdomain. If you choose pic.ngo it will be reachable at name.pic.ngo."
/> />
<div> <div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name"> <label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
@@ -241,13 +254,20 @@ function Step1CellName({ value, onChange, onNext }) {
setServerError(''); setServerError('');
}} }}
onKeyDown={e => e.key === 'Enter' && handleNext()} onKeyDown={e => e.key === 'Enter' && handleNext()}
placeholder="e.g. homelab" placeholder="e.g. myhome"
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" 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} aria-describedby={error || serverError ? 'cell-name-error' : undefined}
/> />
{isValid ? (
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
<Globe className="h-3.5 w-3.5" />
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
</p>
) : (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters. Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters.
</p> </p>
)}
<div id="cell-name-error"> <div id="cell-name-error">
<FieldError message={error || serverError} /> <FieldError message={error || serverError} />
</div> </div>
@@ -263,11 +283,18 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const strength = passwordStrength(password); const strength = passwordStrength(password);
const ready = meetsApiRequirements(password) && password === confirm;
const validate = () => { const validate = () => {
const e = {}; const e = {};
if (!password) e.password = 'Password is required.'; if (!password) {
else if (password.length < 12) e.password = 'Password must be at least 12 characters.'; e.password = 'Password is required.';
return e;
}
if (password.length < 12) e.password = 'Must be at least 12 characters.';
else if (!/[A-Z]/.test(password)) e.password = 'Must contain at least one uppercase letter.';
else if (!/[a-z]/.test(password)) e.password = 'Must contain at least one lowercase letter.';
else if (!/[0-9]/.test(password)) e.password = 'Must contain at least one digit.';
if (!confirm) e.confirm = 'Please confirm your password.'; if (!confirm) e.confirm = 'Please confirm your password.';
else if (password !== confirm) e.confirm = 'Passwords do not match.'; else if (password !== confirm) e.confirm = 'Passwords do not match.';
return e; return e;
@@ -279,14 +306,12 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
if (Object.keys(e).length === 0) onNext(); if (Object.keys(e).length === 0) onNext();
}; };
const isReady = password.length >= 12 && password === confirm;
return ( return (
<div> <div>
<StepHeader <StepHeader
step={2} step={2}
title="Set admin password" title="Set admin password"
description="This password protects access to your cell. Choose something strong and store it safely." description="This password protects access to your cell. At least 12 characters with uppercase, lowercase, and a digit."
/> />
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -313,7 +338,6 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
{/* Strength bar */}
{password.length > 0 && ( {password.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="w-full bg-gray-700 rounded-full h-1"> <div className="w-full bg-gray-700 rounded-full h-1">
@@ -356,7 +380,7 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div> <div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
</div> </div>
</div> </div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} /> <NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!ready} />
</div> </div>
); );
} }
@@ -386,27 +410,182 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
); );
} }
function Step4DDNS({ value, onChange, onNext, onBack }) { function Step4DomainConfig({
domainType, cellName,
customDomain, onCustomDomain,
customMethod, onCustomMethod,
cloudflareToken, onCloudflareToken,
duckdnsToken, onDuckdnsToken,
onNext, onBack,
}) {
const [errors, setErrors] = useState({});
// pic_ngo: just show the derived domain
if (domainType === 'pic_ngo') {
return ( return (
<div> <div>
<StepHeader <StepHeader
step={4} step={4}
title="DDNS provider" title="Your pic.ngo domain"
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup." description="Your cell will be reachable at the address below. HTTPS and DDNS are managed automatically."
/> />
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 text-center mb-4">
<p className="text-xs text-gray-500 mb-2">Your public address</p>
<p className="text-2xl font-mono font-semibold text-white tracking-tight">
{cellName || '…'}.pic.ngo
</p>
<p className="text-xs text-gray-400 mt-3">
DNS and TLS certificates are provisioned automatically via the pic.ngo API.
</p>
</div>
<p className="text-xs text-gray-500">
Not the right name? Go back to step 1 to change your cell name.
</p>
<NavButtons onBack={onBack} onNext={onNext} />
</div>
);
}
// custom domain
const validateCustom = () => {
const e = {};
const dom = customDomain.trim();
if (!dom) {
e.domain = 'Domain name is required.';
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(dom)) {
e.domain = 'Enter a valid domain name (e.g. home.example.com).';
}
if (!customMethod) e.method = 'Select a TLS method.';
if (customMethod === 'cloudflare' && !cloudflareToken.trim())
e.token = 'Cloudflare API token is required.';
if (customMethod === 'duckdns' && !duckdnsToken.trim())
e.token = 'DuckDNS token is required.';
return e;
};
const handleNext = () => {
const e = validateCustom();
setErrors(e);
if (Object.keys(e).length === 0) onNext();
};
const isReady =
customDomain.trim() &&
customMethod &&
(customMethod === 'http01' ||
(customMethod === 'cloudflare' && cloudflareToken.trim()) ||
(customMethod === 'duckdns' && duckdnsToken.trim()));
return (
<div>
<StepHeader
step={4}
title="Domain configuration"
description="Enter your domain and choose how TLS certificates will be obtained."
/>
<div className="space-y-5">
{/* Domain name */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Domain name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={customDomain}
onChange={e => {
onCustomDomain(e.target.value.toLowerCase().trim());
setErrors(p => ({ ...p, domain: '' }));
}}
placeholder="e.g. home.example.com"
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"
/>
<FieldError message={errors.domain} />
</div>
{/* TLS method */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
TLS method <span className="text-red-400">*</span>
</label>
<div className="space-y-2"> <div className="space-y-2">
{DDNS_OPTIONS.map(opt => ( {CUSTOM_METHOD_OPTIONS.map(opt => (
<RadioOption <RadioOption
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
label={opt.label} label={opt.label}
description={opt.description} description={opt.description}
selected={value === opt.value} selected={customMethod === opt.value}
onChange={onChange} onChange={v => {
onCustomMethod(v);
setErrors(p => ({ ...p, method: '', token: '' }));
}}
/> />
))} ))}
</div> </div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} /> <FieldError message={errors.method} />
</div>
{/* Cloudflare token */}
{customMethod === 'cloudflare' && (
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Cloudflare API token <span className="text-red-400">*</span>
</label>
<input
type="password"
autoComplete="off"
value={cloudflareToken}
onChange={e => {
onCloudflareToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
}}
placeholder="Cloudflare API token with DNS:Edit permission"
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"
/>
<p className="mt-1 text-xs text-gray-500">
Create at Cloudflare Dashboard My Profile API Tokens. Needs Zone / DNS / Edit.
</p>
<FieldError message={errors.token} />
</div>
)}
{/* DuckDNS token */}
{customMethod === 'duckdns' && (
<div>
<label className="block text-sm text-gray-400 mb-1.5">
DuckDNS token <span className="text-red-400">*</span>
</label>
<input
type="password"
autoComplete="off"
value={duckdnsToken}
onChange={e => {
onDuckdnsToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
}}
placeholder="Your DuckDNS account token"
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"
/>
<p className="mt-1 text-xs text-gray-500">
Found at duckdns.org after login. The subdomain must already exist in your account.
</p>
<FieldError message={errors.token} />
</div>
)}
{/* HTTP-01 info */}
{customMethod === 'http01' && (
<div className="p-3 bg-yellow-950/40 border border-yellow-700/50 rounded-lg">
<p className="text-xs text-yellow-300">
<span className="font-semibold">Port 80 must be publicly reachable</span> from the
internet for Let's Encrypt HTTP-01 validation. Ensure your router forwards port 80
to this machine before completing setup.
</p>
</div>
)}
</div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
</div> </div>
); );
} }
@@ -426,7 +605,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
description="Choose which services to enable. You can change this later in Settings." description="Choose which services to enable. You can change this later in Settings."
/> />
{/* Optional services */}
<div className="space-y-2 mb-6"> <div className="space-y-2 mb-6">
{OPTIONAL_SERVICES.map(svc => { {OPTIONAL_SERVICES.map(svc => {
const checked = selected.includes(svc.key); const checked = selected.includes(svc.key);
@@ -452,7 +630,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
})} })}
</div> </div>
{/* Always-on services */}
<div> <div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2"> <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Always enabled Always enabled
@@ -538,10 +715,18 @@ function ReviewRow({ label, value }) {
} }
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) { function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type; const domainDisplay =
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider; fields.domain_type === 'pic_ngo' ? `${fields.cell_name}.pic.ngo` :
const serviceLabels = fields.services.length fields.domain_type === 'lan' ? 'LAN only (no public domain)' :
? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ') fields.custom_domain || '(not set)';
const tlsDisplay =
fields.domain_type === 'pic_ngo' ? 'Automatic (pic.ngo)' :
fields.domain_type === 'lan' ? '—' :
CUSTOM_METHOD_OPTIONS.find(o => o.value === fields.custom_method)?.label || fields.custom_method;
const serviceLabels = (fields.services_enabled || []).length
? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
: 'None selected'; : 'None selected';
return ( return (
@@ -554,9 +739,9 @@ function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2"> <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="Cell name" value={fields.cell_name} />
<ReviewRow label="Admin password" value="••••••••••••" /> <ReviewRow label="Admin password" value="••••••••••••" />
<ReviewRow label="Domain type" value={domainLabel} /> <ReviewRow label="Domain" value={domainDisplay} />
{fields.domain_type !== 'lan_only' && ( {fields.domain_type !== 'lan' && (
<ReviewRow label="DDNS provider" value={ddnsLabel} /> <ReviewRow label="TLS / DNS" value={tlsDisplay} />
)} )}
<ReviewRow label="Optional services" value={serviceLabels} /> <ReviewRow label="Optional services" value={serviceLabels} />
<ReviewRow label="Timezone" value={fields.timezone} /> <ReviewRow label="Timezone" value={fields.timezone} />
@@ -591,7 +776,10 @@ export default function Setup() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
const [domainType, setDomainType] = useState('pic_ngo'); const [domainType, setDomainType] = useState('pic_ngo');
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo'); const [customDomain, setCustomDomain] = useState('');
const [customMethod, setCustomMethod] = useState('');
const [cloudflareToken, setCloudflareToken] = useState('');
const [duckdnsToken, setDuckdnsToken] = useState('');
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']); const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState(
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })() (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
@@ -601,39 +789,46 @@ export default function Setup() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const skipDdns = domainType === 'lan_only'; const skipStep4 = domainType === 'lan';
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS)); const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
const goBack = () => setStep(s => Math.max(s - 1, 1)); const goBack = () => setStep(s => Math.max(s - 1, 1));
// Skip step 4 when LAN only const handleStep3Next = () => skipStep4 ? setStep(5) : setStep(4);
const handleStep3Next = () => {
if (skipDdns) setStep(5);
else setStep(4);
};
const handleStep4Back = () => setStep(3); const handleStep4Back = () => setStep(3);
const handleStep5Back = () => { const handleStep5Back = () => skipStep4 ? setStep(3) : setStep(4);
if (skipDdns) setStep(3);
else setStep(4);
};
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitError(''); setSubmitError('');
setSubmitting(true); setSubmitting(true);
const domainMode = getDomainMode(domainType, customMethod);
const domainName =
domainType === 'pic_ngo' ? `${cellName}.pic.ngo` :
domainType === 'lan' ? '' :
customDomain;
const payload = { const payload = {
cell_name: cellName, cell_name: cellName,
password, password,
domain_type: domainType, domain_mode: domainMode,
...(skipDdns ? {} : { ddns_provider: ddnsProvider }), domain_name: domainName,
services,
timezone, timezone,
services_enabled: services,
...(domainType !== 'lan' && {
ddns_provider: domainType === 'pic_ngo' ? 'pic_ngo' : customMethod === 'http01' ? 'none' : customMethod,
}),
...(customMethod === 'cloudflare' && { cloudflare_api_token: cloudflareToken }),
...(customMethod === 'duckdns' && { duckdns_token: duckdnsToken }),
}; };
try { try {
await setupAPI.complete(payload); await setupAPI.complete(payload);
setDone(true); setDone(true);
setTimeout(() => navigate('/login', { replace: true }), 2000); setTimeout(() => navigate('/login', { replace: true }), 2000);
} catch (e) { } catch (e) {
setSubmitError( setSubmitError(
e?.response?.data?.errors?.join(' ') ||
e?.response?.data?.error || e?.response?.data?.error ||
'Setup could not be completed. Please check your entries and try again.' 'Setup could not be completed. Please check your entries and try again.'
); );
@@ -642,7 +837,14 @@ export default function Setup() {
} }
}; };
const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone }; const reviewFields = {
cell_name: cellName,
domain_type: domainType,
custom_domain: customDomain,
custom_method: customMethod,
services_enabled: services,
timezone,
};
if (done) { if (done) {
return ( return (
@@ -659,7 +861,6 @@ export default function Setup() {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10"> <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"> <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"> <div className="mb-6">
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1> <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> <p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
@@ -684,7 +885,20 @@ export default function Setup() {
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} /> <Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
)} )}
{step === 4 && ( {step === 4 && (
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} /> <Step4DomainConfig
domainType={domainType}
cellName={cellName}
customDomain={customDomain}
onCustomDomain={setCustomDomain}
customMethod={customMethod}
onCustomMethod={setCustomMethod}
cloudflareToken={cloudflareToken}
onCloudflareToken={setCloudflareToken}
duckdnsToken={duckdnsToken}
onDuckdnsToken={setDuckdnsToken}
onNext={goNext}
onBack={handleStep4Back}
/>
)} )}
{step === 5 && ( {step === 5 && (
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} /> <Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
@@ -694,7 +908,7 @@ export default function Setup() {
)} )}
{step === 7 && ( {step === 7 && (
<Step7Review <Step7Review
fields={allFields} fields={reviewFields}
onBack={goBack} onBack={goBack}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitting={submitting} submitting={submitting}