When the bash installer collects cell name and domain mode, the first-run
wizard's /setup should only ask for a password, service selection, and
timezone. Previously the wizard pre-filled those fields but still showed
all 7 steps.
- useEffect fetches /api/setup/status on mount; if preconfigured.cell_name
and preconfigured.domain_mode are both set, sets installerConfigured=true
and jumps to step 2 (password)
- handleStep2Next → step 5 when installerConfigured (skips domain steps 3+4)
- handleStep2Back → step 1 when installerConfigured (review cell name)
- handleStep5Back returns to step 2 when installerConfigured
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DOMAIN_MODE, CELL_DOMAIN_NAME, CLOUDFLARE_API_TOKEN, DUCKDNS_TOKEN,
DUCKDNS_SUBDOMAIN are now explicit in the setup target so they are
visible and documented, not silently inherited from the environment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DDNS registration (setup_cell.py):
- Replace pyotp dependency with stdlib TOTP (HMAC-SHA1, RFC 6238)
pyotp is only available inside the Docker container, not on the host
where setup_cell.py runs — registration was silently skipped every time
- OTP header still sent if generation succeeds; omitted gracefully if not
Wizard pre-fill (setup_manager + Setup.jsx):
- GET /api/setup/status now returns 'preconfigured' dict with cell_name,
domain_mode, domain_name, and provider tokens from installer-written config
- Setup.jsx fetches status on mount and pre-fills all form state so the
user only needs to set password, services, and timezone — not re-enter
the identity they already configured in the bash installer
- Fails silently so wizard still works on fresh installs with no config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
install.sh now guides the user through the full identity setup before
running make install:
- Cell name prompt with format validation and pic.ngo availability check
- Domain mode selection: pic.ngo / Cloudflare / DuckDNS / HTTP-01 / LAN
- Cloudflare API token: collected and verified against CF tokens/verify API
- DuckDNS: subdomain + token verified against duckdns.org/update
- HTTP-01: domain name collected, port-80 warning shown
- All collected values passed as env vars to make install
- After two failed token attempts user can continue (re-verified at boot)
- Final banner shows configured cell name and domain
setup_cell.py: updated to handle all domain modes
- Reads DOMAIN_MODE / CELL_DOMAIN_NAME / CLOUDFLARE_API_TOKEN /
DUCKDNS_TOKEN / DUCKDNS_SUBDOMAIN from env
- write_cell_config() now writes domain_mode + domain_name to _identity
and builds the ddns section for each provider (not hardcoded to pic_ngo)
- register_with_ddns() only called when DOMAIN_MODE == 'pic_ngo'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- 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>
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>
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>
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>
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>
Option A is now the one-line curl installer (install.pic.ngo); Option B
is the manual git clone path. Wizard section covers all five domain modes
(pic_ngo, cloudflare, duckdns, http01, lan) and current password rules.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
README, QUICKSTART, and Wiki were pre-wizard, pre-auth, pre-DDNS, and
pre-service-store. Full rewrite covering:
- First-run wizard replaces manual make setup + .env identity config
- Session-based auth (admin/peer roles, CSRF protection)
- DDNS: pic.ngo registration with TOTP, provider abstraction
- Service store: install/remove optional services from manifest index
- Cell-to-cell networking and peer-sync protocol
- Extended connectivity: WG external, OpenVPN, Tor exit routing
- Caddy HTTPS: Let's Encrypt (DNS-01/HTTP-01) or internal CA
- Current container list, port bindings, and security model
- Accurate make targets (ddns-update, reset-admin-password, etc.)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- auth_manager._ensure_file(): stop creating the empty auth_users.json on
init — the constructor now only creates the parent directory. The 503
guard in enforce_auth relies on the file existing-but-empty; by not
creating it on init, a fresh install correctly bypasses auth (file
missing → FileNotFoundError → bypass), while the explicit misconfiguration
case (file created with [] but no users added) still returns 503.
- test_enforce_auth_configured.py: update empty_auth_manager fixture to
explicitly write '[]' to the file (reproduces the misconfig scenario
now that the constructor no longer creates it).
- ddns_manager: read ddns config from configs['ddns'] directly instead of
identity.domain.ddns — _identity.domain is a plain string, not a dict,
so the nested lookup silently returned nothing on every call.
- setup_cell.py: write top-level 'ddns' block into cell_config.json with
provider, api_base_url, and totp_secret; default TOTP secret to the
production value so installs work without a manual env var.
- test_ddns_manager.py: update _make_config_manager to populate cm.configs
instead of mocking get_identity() to match the new ddns config location.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setup_cell.py: register_with_ddns() called at end of setup — detects
public IP via api.ipify.org, generates TOTP code from DDNS_TOTP_SECRET,
POSTs to DDNS /register, saves token to data/api/.ddns_token (mode 600).
Idempotent: skips if token file already exists. Fails gracefully if
DDNS_TOTP_SECRET is unset or network is unreachable.
scripts/ddns_update.py: standalone script for periodic IP updates.
Reads token from data/api/.ddns_token, fetches current public IP,
compares to cached last IP (data/api/.ddns_last_ip) and calls /update
only when the IP has actually changed.
Makefile: add ddns-update (run update script) and ddns-register (force
re-registration by removing old token then calling register_with_ddns).
Usage: DDNS_TOTP_SECRET=<secret> make ddns-register
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
config/api/dns/, config/api/network.json, config/api/webdav/ are
created at API startup and should never be tracked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- test.yml: run unit tests on every push (all branches)
- release.yml: build and push pic-api + pic-webui images on v*.*.* tags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sends 50 pings at 0.2s intervals through the cell-to-cell tunnel and
asserts that ≤5% exceed 3× the median RTT (floor 15ms). Catches
server-side packet processing regressions on wired paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Concurrent callers (health monitor + startup) could both pass the
delete-all loop and each insert a copy, producing duplicate
ESTABLISHED,RELATED rules. Lock serialises all calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pic1 ships alpine but not busybox; ensure_cell_subnet_routes() now uses
the alpine image so route injection works on all cells.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- firewall_manager: add _get_wg_server_ip() helper; scope ensure_cell_api_dnat(),
ensure_dns_dnat(), ensure_service_dnat() DNAT rules with -d server_ip; add
ensure_wg_masquerade() (Docker→wg0 MASQUERADE+FORWARD) and
ensure_cell_subnet_routes() (host routes via docker run busybox)
- wireguard_manager: scope PostUp DNAT rules with -d server_ip in generate_config()
and ensure_postup_dnat(); add Docker→wg0 MASQUERADE+FORWARD rules
- app.py: call ensure_wg_masquerade() and ensure_cell_subnet_routes() in
_apply_startup_enforcement()
- tests/test_firewall_manager.py: mock _get_wg_server_ip, add
test_dnat_is_scoped_to_server_ip and test_returns_false_when_wg_server_ip_not_found
- tests/e2e/wg/test_cell_to_cell_routing.py: rewrite to use dynamic config
(no hardcoded IPs/ports), add latency and domain access tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The cell catch-all DROP rule blocked all traffic from a connected cell's
subnet, including ESTABLISHED/RELATED packets (ICMP replies, TCP ACKs) for
connections initiated by local VPN peers. This broke ping to the remote
cell's WireGuard IP even when the cell-to-cell tunnel was healthy.
Change the DROP to match only NEW,INVALID connections so established reply
traffic passes through to the stateful ACCEPT rule.
Also adds tests/e2e/wg/test_cell_to_cell_routing.py — an end-to-end test
that brings up a real WireGuard tunnel from the test runner to pic1 and
verifies full cross-cell routing including ICMP ping, API /health, and Caddy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apply_cell_rules drops all traffic from a cell's subnet except specific
service ports. This also drops ICMP replies and TCP ACKs for connections
initiated by local peers to the connected cell, breaking cross-cell
routing (ping to 10.0.0.1 silently dropped by test's cell DROP rule).
Fix: ensure_forward_stateful() inserts a stateful ESTABLISHED,RELATED
ACCEPT at the top of FORWARD. Called from apply_cell_rules (every cell
add/update) and from _apply_startup_enforcement. Idempotent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three related fixes for split-tunnel peers that need to reach connected cells:
1. apply_peer_rules/apply_all_peer_rules now accept wg_subnet (actual local VPN
subnet) and cell_subnets (connected cells' vpn_subnets) parameters instead of
hardcoding 10.0.0.0/24. All callers (startup, add_peer, update_peer,
apply-enforcement endpoint) pass the real values.
2. Explicit ACCEPT rules are inserted in FORWARD for each connected cell's
subnet so split-tunnel peers (internet_access=False) can still reach
connected cells via the wg0→wg0 path.
3. apply_ip_range in network_manager now loads cell_links.json and passes it
to generate_corefile(), fixing a race where the bootstrap DNS thread could
overwrite the Corefile and wipe cross-cell DNS forwarding zones on startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a cell is connected to others, changing the local WireGuard address
or Docker ip_range to a subnet that overlaps a connected cell's vpn_subnet
would break routing. Both now return 409 with the conflicting cell name.
- wireguard.address: derive network from new address, check all connected
cells' vpn_subnet for overlap (after existing format validation)
- ip_range: check all connected cells' vpn_subnet for overlap (after
existing RFC-1918 validation)
Tests: 4 cases each (overlap → 409, no overlap → ok, no cells → ok,
format error still fires first → 400).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two gaps allowed a cell to take a domain already in use by a connected cell:
1. PUT /api/config domain change: added check against cell_link_manager's
connected cells list before saving — returns 409 if the new domain
collides with any connected cell's domain.
2. accept_invite healing path: a remote cell changing its domain via a
re-invite was not validated against other connected cells' domains.
Now calls _check_invite_conflicts(invite, exclude_cell=name) before
applying any change.
Also: the healing path now detects domain changes (alongside dns_ip/
vpn_subnet/endpoint), updates the stored domain, and refreshes the DNS
forward rule when the domain changes.
Tests: 3 new domain-conflict tests in test_config_validation.py;
3 new accept_invite healing tests in test_cell_link_manager.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Flask's default cookie name ('session') is shared across all ports on the same
hostname. When two PIC instances are accessed via localhost:portA and localhost:portB,
logging into one overwrites the other's session cookie, causing repeated logouts.
Derive a unique 8-hex suffix from each instance's persistent SECRET_KEY and set
SESSION_COOKIE_NAME = 'pic_sess_<suffix>'. This ensures each cell uses a distinct
cookie name, so sessions are fully isolated regardless of hostname.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_get_dnat_container_ips() used a concatenating docker inspect format that
produced "invalid IP" when containers had multiple network attachments.
The old ensure_postup_dnat appended rather than replacing, so each update
call added a broken duplicate set of rules causing iptables to fail on
startup and tear down wg0 entirely.
Fix _get_dnat_container_ips to use a space separator in the format string
and validate each token as a real IP before accepting it.
Rewrite ensure_postup_dnat with _is_dnat_rule() helper: strips every
managed DNAT/FORWARD rule (any IP, port 53/80) on semicolon-split and
appends a single correct set — fully idempotent regardless of prior state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Login.jsx:
- Eye/EyeOff toggle on the password field
- Locked account error now shows exact minutes remaining ("Try again in 3 minutes")
instead of generic "Try again later"
AccountSettings.jsx:
- PasswordInput component wraps all 4 password fields with individual eye toggles
(current password, new password, confirm, admin reset)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 new tests in TestSetPeerRouteVia:
- 409 when remote_exit_relay_active=True (would create A→B→A cycle)
- disable (via_cell=null) bypasses loop check — always allowed
- no 409 when remote_exit_relay_active=False (safe to enable)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three issues fixed together:
1. WireGuard address changes now go through the pending-restart queue
(shown in the UI banner) instead of restarting cell-wireguard immediately.
Only private_key changes still restart immediately; address and port
changes both defer to the user-initiated Apply flow. Previously the
address change was silently applied and never appeared in Settings →
Pending Configuration.
2. When the WG address changes, the API spawns a background thread that
pushes the updated invite to all connected cells (over LAN, before the
WG tunnel is back up). This lets remote cells automatically update
their dns_ip, AllowedIPs, and CoreDNS forwarding rules without manual
re-pairing.
3. accept_invite now handles the "already connected but changed" case:
if the remote cell re-sends an invite with a different dns_ip, vpn_subnet
or endpoint, we update the stored link, the WG AllowedIPs, and the
CoreDNS forward rule in place — no delete/re-add required. Previously
the endpoint was ignored and returned the stale record unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>