'unreachable' should not be a terminal state that triggers auto-save —
it was causing a 503 when the availability check failed and auto-save
fired the backend registration attempt. Only 'available' allows
auto-save when the cell name has changed from the loaded value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a short label explaining the button is for DDNS recovery (when the
DDNS server lost your record), not routine IP updates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Auto-save was firing with picAvail === null (the moment the user typed a
new cell name, before the 900ms availability debounce even started), which
caused the backend to immediately register the subdomain on DDNS.
Track the last saved/loaded cell name in loadedCellName. When domainMode
is pic_ngo and the typed name differs from the loaded name, block
auto-save until picAvail reaches a terminal state (available or
unreachable). Also update loadedCellName on successful save so subsequent
edits to the same name are not blocked.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DELETE /api/v1/registration to the DDNS server (token-authenticated,
owner-only) and PicNgoDDNS.release() on the client. DDNSManager.register()
now automatically releases the old subdomain before claiming the new one,
so stale names are freed for others to use. Release failures are logged as
warnings and do not block the new registration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs that prevented registration from working after wizard completion:
1. register(name, '') sent empty IP; server stored blank A record. Now calls
_get_public_ip() when ip is empty so the A record is always set correctly.
2. Token was saved to _identity.domain.ddns.token (TypeError when domain is a
string) instead of the top-level ddns config where update_ip() reads it.
Subdomain also now correctly written to _identity.domain_name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DDNSTokenExpired exception triggers auto re-register in update_ip()
so cells recover silently after a DDNS DB reset
- POST /api/ddns/register lets the user force re-registration from Settings
- Re-register button in Settings → External Domain & DDNS (pic_ngo only)
- 3 new tests covering register endpoint: wrong provider, missing name, success
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ddns_manager: DDNS_URL env var overrides stored api_base_url so
existing cells pick up the new HTTPS endpoint without re-registering
- docker-compose.yml: default DDNS_URL now points to https://ddns.pic.ngo
- setup_manager.py: add rstrip('/') before replacing /api/v1 to handle
URLs with or without trailing slash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /api/config now returns domain_mode, domain_name, ddns.{provider,subdomain,has_token}
- GET /api/ddns/check/<name> proxies availability check to DDNS service
- PUT /api/ddns validates and saves cloudflare/duckdns credentials post-setup
- When cell_name changes for pic_ngo provider, auto-registers the new subdomain
- Settings: Cell Name shows availability badge for pic_ngo; auto-save blocks on taken
- Settings: new External Domain & DDNS section — pic_ngo info, cloudflare/duckdns edit
- 11 new tests for the two new endpoints (all pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker-compose.yml DDNS_TOTP_SECRET defaulted to empty string —
containers on fresh installs had no OTP, so every /register call
was rejected with 401 and no domain was ever registered.
setup_cell.py still pointed to https://ddns.pic.ngo/api/v1 (no nginx
on VPS, so HTTPS fails). Both now default to the correct values; both
are still overridable via env var for custom DDNS deployments.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_check_pic_ngo_available was hardcoding https://ddns.pic.ngo, ignoring
DDNS_URL. Now imports DDNS_API_BASE from setup_manager so both the
availability check and DDNS registration use the same configured URL.
API container now receives DDNS_URL and DDNS_TOTP_SECRET from env.
Default DDNS_URL points to http://ddns.pic.ngo:8080/api/v1 (the
FastAPI service runs on port 8080 without TLS termination in front).
Also returns 503 (not 500) when the DDNS service is unreachable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
For pic_ngo: name must be confirmed available (not just format-valid).
For cloudflare/duckdns: token is auto-verified on Next if not already
done — invalid or unreachable service blocks proceeding. Only lan and
http01 (no external dependency) allow Next without a live check.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The availability check was only triggered onBlur, so clicking Next
without blurring the field skipped the DDNS request entirely. Now
handleNext awaits the check and blocks with an error if the name is
taken. Unknown/unreachable DDNS is treated as available to avoid
blocking the wizard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Domain name is now the cell identity (no separate cell name step).
All 5 providers (pic_ngo, cloudflare, duckdns, http01, lan) are
first-class options in a single Domain step. pic.ngo availability
is checked live via backend proxy to ddns.pic.ngo. Cloudflare and
DuckDNS tokens are verified via backend before proceeding.
cell_name is derived automatically from the chosen domain.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs:
1. AttributeError: AuthManager.update_password does not exist — the
fallback when create_user fails should call set_password_admin().
This caused a 500 on every setup submit when an admin user already
existed (e.g. from a previous install attempt).
2. Wizard was jumping to step 2 and skipping domain steps 3-4 when
preconfigured data existed in cell_config.json. Since the installer
no longer sets that data, and the wizard must always show all steps,
the installerConfigured state and all step-skipping navigation is
removed. Values are still pre-filled if found in config.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
install.sh no longer prompts for anything. It installs packages (with sudo),
creates the system user, clones the repo, and runs 'make install' — all as
the invoking user. Only package installs and system-level ops use sudo.
All folder creation happens under the user's own account, no chown needed.
/setup wizard gains the missing validation that was previously in install.sh:
- Step 1: checks pic.ngo name availability via backend (non-blocking)
- Step 4: 'Verify token' button for Cloudflare and DuckDNS tokens,
validated server-side through new /api/setup/validate steps
API changes (routes/setup.py):
- validate step 'pic_ngo_available': proxy check to ddns.pic.ngo
- validate step 'cloudflare_token': verify via Cloudflare tokens API
- validate step 'duckdns_token': verify via DuckDNS update endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reverts 8d1ef39. The installer must collect cell name, domain mode, and
provider tokens before 'make install' so that DDNS registration,
availability checks, and Caddy TLS can be configured at first boot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>