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>
DNAT rules applied via docker exec are lost whenever wg-easy reloads the
WireGuard interface (PostDown flushes the nat table then PostUp only
re-adds static rules). Fix: embed DNS (port 53) and service (port 80)
DNAT rules directly in wg0.conf PostUp/PostDown so they reapply on every
interface restart. ensure_postup_dnat() patches existing configs on startup.
get_server_config() now returns the WG server IP (e.g. 10.0.0.1) for
dns_ip instead of the cell-dns container IP (172.20.0.3). This makes the
value consistent with what get_peer_config() writes into the .conf file,
and fixes the stale hint text in Peers.jsx and WireGuard.jsx.
UI: fallback dns_ip changed from 172.20.0.3 to 10.0.0.1; split-tunnel
fallback drops the 172.20.0.0/16 stale range.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Locks vitest + @testing-library versions added in 94957ab so
make test-webui is reproducible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CellNetwork page (CellPanel):
- Internet Sharing section below service toggles
- Toggle: 'Offer my internet to <cell>' (calls PUT /api/cells/<n>/exit-offer)
- Read-only indicator: whether remote cell offers internet back
- Contextual hints explaining what each party needs to do next
Peers page:
- Fetches connected cells on mount
- Edit modal: Internet Exit dropdown (route-via) showing all connected cells
with ✓ marker for cells that have offered internet
- Warning if selected cell hasn't offered internet yet
- On save, calls PUT /api/peers/<n>/route-via only when value changed
- Table badge shows 'via <cell>' for peers with active routing
api.js:
- cellLinkAPI.setExitOffer(cellName, offered)
- peerRegistryAPI.setRouteVia(peerName, viaCell)
Tests (vitest + @testing-library/react):
- 19 new frontend tests in src/__tests__/
- CellNetworkInternetSharing.test.jsx (10 tests)
- PeersRouteVia.test.jsx (9 tests)
- make test-webui target runs them via docker node:18-alpine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /api/wireguard/peers/statuses endpoint returns {pubkey: {online,...}}
not {peers: [{public_key,...}]}. The status mapping loop was always
producing an empty statusByKey, making every connected cell show Offline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CellNetwork.jsx CopyButton: use execCommand fallback when clipboard API
is unavailable (HTTP non-localhost context)
- Makefile reset-admin-password: run inside cell-api container via docker exec
so bcrypt and all deps are available without host installation
- docker-compose.yml: mount ./scripts:/app/scripts:ro in cell-api so the
reset script is accessible inside the container
- scripts/reset_admin_password.py: auto-detect API module path and data dir
so the script works in both host (api/ sibling) and container (/app) layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- API key store was out of sync with wg0.conf: get_keys() generated a random
phantom key instead of reading the actual WireGuard server key, so all peer
configs had the wrong PublicKey and could never handshake. Fixed by writing
correct raw-bytes key files at deploy time and adding _sync_wg_keys() to API
startup so the store auto-syncs from wg0.conf on every restart.
- apply_domain() fell back silently when zone file had no $ORIGIN directive;
now also parses the SOA MNAME as the old-domain fallback.
- apply_cell_name() only replaced the hostname if old_name matched literally
in the zone file; now auto-detects the actual hostname (non-service A record)
so a stale zone (mycell vs dev) is corrected on next config apply.
- DNS zone file corrected: SOA pic.ngo. admin.pic.ngo., mycell → dev.
- WireGuard UI: add 30s auto-poll for peer statuses; fix "peers currently
connected" counter to show online/total instead of total count.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- check_csrf() now issues a token for sessions that predate CSRF (existing logins) instead of blocking them
- /api/wireguard/check-port and /api/wireguard/refresh-ip accept GET so native fetch calls bypass the token requirement
- WireGuard.jsx: changed three native fetch POST → GET for the above endpoints
- Peers.jsx: add X-CSRF-Token header to three native fetch mutation calls (calendar collection, peer PUT, clear-reinstall)
- api.js: export getCsrfToken() so non-Axios callers can read the current token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- network_manager: api/webui DNS records now point to Caddy (172.20.0.2)
instead of their container IPs so Caddy can reverse-proxy correctly
- ip_utils: add webui.dev block to generated Caddyfile
- config/caddy/Caddyfile: regenerated with webui.dev block
- config/dns/Corefile: simplify to single forward zone (remove duplicate)
- app.py peer_dashboard: rename peer_name→name, rx_bytes→transfer_rx,
tx_bytes→transfer_tx to match PeerDashboard.jsx; add service_urls dict
- app.py peer_services: fix DNS (10.0.0.1→real CoreDNS IP), CalDAV URL
(radicale.dev:5232→calendar.dev), email structure (flat→nested smtp/imap
objects), rename webdav→files, add WireGuard config text, add username field
- PeerDashboard.jsx: render service icon links from service_urls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. make reset/show-admin-password: use sudo so data/api/ owned-by-root
files are writable without explicit sudo prefix
2. Peers.jsx: remove one-time password modal on peer creation — admin
already knows the password they typed; replace with a success toast
showing peer name and provisioned accounts
3. WireGuard.jsx + Peers.jsx: add credentials:'include' to every raw
fetch() call (7 calls across two files, plus fix one hardcoded
localhost:3000 URL); the port check and peer status calls were
returning 401 because they didn't send the session cookie
4. test_admin_wireguard.py: update test to match new toast flow (no modal),
add Scenario 10 test that verifies the port check badge renders on the
WireGuard page after the credentials fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The devops security pass incorrectly bound the webui to 127.0.0.1,
making it unreachable from the network. The webui is the user-facing
interface and must be publicly accessible. Internal-only services
(api :3000, radicale :5232, webdav :8080, rainloop :8888,
filegator :8082) retain their loopback bindings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- AuthManager (api/auth_manager.py): server-side user store with bcrypt
password hashing, account lockout after 5 failed attempts (15 min),
and atomic file writes
- AuthRoutes (api/auth_routes.py): Blueprint at /api/auth/* — login,
logout, me, change-password, admin reset-password, list-users
- app.py: register auth_bp blueprint; add enforce_auth before_request
hook (401 for unauthenticated, 403 for wrong role; only active when
auth store has users so pre-auth tests remain green); instantiate
AuthManager; update POST /api/peers to require password >= 10 chars
and auto-provision email + calendar + files + auth accounts with full
rollback on any failure; extend DELETE /api/peers to tear down all
four service accounts; add /api/peer/dashboard and /api/peer/services
peer-scoped routes; fix is_local_request to also trust the last
X-Forwarded-For entry appended by the reverse proxy (Caddy)
- Role-based access: admin for /api/* (except /api/auth/* which is
public and /api/peer/* which is peer-only)
- setup_cell.py: generate and print initial admin password, store in
.admin_initial_password with 0600 permissions; cleaned up on first
admin login
Frontend:
- AuthContext.jsx: React context with login/logout/me state and Axios
interceptor for automatic 401 redirect
- PrivateRoute.jsx: route guard component
- Login.jsx: login page with error handling and must-change-password
redirect
- AccountSettings.jsx: change-password form for any authenticated user
- PeerDashboard.jsx: peer-role landing page (IP, service list)
- MyServices.jsx: peer service links page
- App.jsx, Sidebar.jsx: AuthContext integration, logout button,
PrivateRoute wrappers, peer-role routing
- Peers.jsx, WireGuard.jsx, api.js: auth-aware API calls
Tests: 100 new auth tests all pass (test_auth_manager, test_auth_routes,
test_route_protection, test_peer_provisioning). Fix pre-existing test
failures: update WireGuard test keys to valid 44-char base64 format
(test_wireguard_manager, test_peer_wg_integration), add password field
and service manager mocks to test_api_endpoints peer tests, add auth
helpers to conftest.py. Full suite: 845 passed, 0 failures.
Fixed: .admin_initial_password security cleanup on bootstrap, username
minimum length (3 chars enforced by USERNAME_RE regex)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: sysctl -q net.ipv4.conf.all.rp_filter=0 in PostUp exited non-zero
inside the linuxserver/wireguard container (no permission), causing wg-quick to
tear down the wg0 interface — breaking peer status, port check, and internet
access through full tunnel.
- wireguard_manager.py: add || true to both sysctl PostUp/PostDown lines
- docker-compose.yml: add net.ipv4.conf.all.rp_filter=0 to wireguard sysctls
- WireGuard.jsx: kick off port check asynchronously on page load (was refresh-only)
- tests: add TestWireGuardSysctlAndPortCheck — 14 new tests covering sysctl
content, check_port_open (interface up / down / fallback-to-handshake),
get_peer_status (online / offline / not-found / no-handshake), and
get_all_peer_statuses (multi-peer / empty / skips interface line)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- isValidDomain / isValidDomainOrIp helpers (RFC-compliant label regex)
- network.ntp_servers: each entry validated as hostname or IP; invalid entry shown in error message
- email.domain: validated as a proper domain name; blocks autosave until fixed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- export_config: clean output (no internal _keys), identity exposed as 'identity'
- import_config: handle 'identity' key, merge into existing config (not replace)
- restore_config: accept optional services list for selective restore
- backup_config: include 'identity' in manifest services list
- new GET /api/config/backups/<id>/download → zip file download
- new POST /api/config/backup/upload → zip file upload
- webui: Download + Upload buttons, restore modal with per-service checkboxes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Settings: remove Save buttons; autosave is silent (no toast on success, error only)
- Settings: loadAll() resets dirty flags to prevent stale autosave after discard
- app.py: fix domain/ip_range "actually changed" check — full identity is always
sent on save so these were triggering pending on every keystroke regardless
- app.py: _dedup_changes handles port-change format "service field: old → new"
(split on ':' not ' changed') so dns_port changed twice shows one entry
- app.py: domain + cell_name changes now go through pending restart banner;
apply_domain/apply_cell_name write files immediately (reload=False) and set
pending; Discard restores zone files + Caddyfile to pre-change state
- app.py: _set_pending_restart captures pre-change snapshot BEFORE config writes
(was snapshotting after, making Discard a no-op)
- app.py: is_local_request reads /proc/net/route to allow the actual Docker
bridge subnet (172.0.0.0/24) which is not RFC-1918; fixes Containers page 403
- container_manager: get_container_logs raises instead of swallowing exceptions
so nonexistent container returns 500+error not 200+empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Autosave on Apply (was broken):
- App.jsx called useDraftConfig() in the same component that rendered
DraftConfigProvider — a component cannot consume context it provides.
Fixed by splitting into AppCore (consumes context, all logic) and App
(thin shell that wraps AppCore in DraftConfigProvider). The hook now
runs inside the provider and hasDirty()/flushAll() work correctly.
Cell name / domain length validation (255-char DNS standard):
- api/app.py: reject cell_name or domain > 255 chars or empty with 400
- api/app.py: reject ip_range without CIDR prefix (bare IPs shift all VIPs)
- webui/src/pages/Settings.jsx: cellNameError + domainError computed values
block saveIdentity and show inline error; maxLength={255} on inputs
- tests/test_identity_validation.py: 8 unit tests for the new validation
Cell name overflow on all pages:
- Dashboard.jsx: add min-w-0 to flex child div + truncate + title on cell_name
- CellNetwork.jsx: min-w-0 + truncate + title on cell_name, domain, endpoint,
vpn_subnet in invite cards and connected-cells list
Apply-and-verify integration tests:
- tests/integration/test_apply_propagation.py: TestPendingState (no restarts)
and TestApplyAndVerify (triggers real container restart + health poll)
covering the full save → apply → wait → verify propagation lifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port conflict validation:
- api/port_registry.py: detect_conflicts() checks all service sections for shared port values
- api/app.py: returns HTTP 409 on port conflict after existing range validation
- webui/src/pages/Settings.jsx: JS-side detectPortConflicts() with useMemo shows inline
conflict errors and blocks Save before the request is made; catch blocks surface server
error messages (including 409) instead of generic fallbacks
Config autosave on Apply:
- webui/src/contexts/DraftConfigContext.jsx: new context; Settings registers flush callbacks
per section; App calls flushAll() before applyPending() when any section is dirty
- webui/src/App.jsx: wraps tree with DraftConfigProvider, handleApply shows 'saving' banner
state and awaits flushAll()
- webui/src/pages/Settings.jsx: registers identity + per-service flushers; propagates dirty
state into context via setDirty; uses refs to avoid stale closures
Extended integration test coverage (114 new tests):
- tests/integration/test_config_api.py: GET/PUT config, export, import, backup lifecycle
- tests/integration/test_network_services.py: DNS records + DHCP reservations CRUD
- tests/integration/test_containers.py: list, restart, logs, stats; recovery polling
- tests/integration/test_negative_scenarios.py: error-path coverage for all endpoints
- tests/test_port_conflicts.py: 20 unit tests for port_registry.detect_conflicts()
Pre-commit hook updated to skip tests/integration/ (live-stack tests require a running
stack and must be run explicitly via `make test-integration`).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UI: validateServiceConfig() checks all port fields (1–65535) and
WireGuard address (IP/CIDR) on every keystroke; Save button is
disabled and saveService() guards against any field errors.
API: update_config() rejects out-of-range port values and invalid
WireGuard address before persisting, returning 400 with a clear
field path (e.g. email.smtp_port).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
API: rejects ip_range outside 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16
with a 400 error before saving to config.
UI: isRFC1918Cidr() validates on every keystroke; error message shown inline
below the field; Save Identity button disabled while the value is invalid.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- docker-compose: fix WireGuard port mapping to ${WG_PORT}:${WG_PORT} so
the daemon ListenPort matches the Docker host-to-container binding
- app.py: sync wireguard.port ↔ identity.wireguard_port in both directions
so changing either keeps them consistent; identity path now also updates
wg0.conf via wireguard_manager.update_config
- Settings.jsx: remove duplicate wireguard_port from Cell Identity section
(port is configurable under WireGuard VPN service config); add
refreshConfig() after saveService so other pages see new values immediately
- WireGuard.jsx: import useConfig() and use service_configs.wireguard.port
as the reactive port source for endpoint display and port-open warnings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Service pages (Email, Calendar, Files) now read IPs and ports from the
config API instead of hardcoded 172.20.0.x constants:
- GET /api/config now includes service_ips (dns, vip_mail, vip_calendar,
vip_files, vip_webdav) computed from ip_range via ip_utils
- Email.jsx: mailIp, dnsIp, imapPort, smtpPort, webmailPort from context
- Calendar.jsx: calendarIp, dnsIp, calendarPort from context
- Files.jsx: filesIp, webdavIp, webdavPort, filegatorPort from context
Apply button now shows restart progress:
- "Restarting containers — please wait…" spinner while polling /health
- "Containers restarted successfully" on success (clears after 4s)
- "Timed out" / error message if health doesn't come back in 45s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DELETE /api/config/pending endpoint calls _clear_pending_restart()
- cellAPI.cancelPending() calls the new endpoint
- PendingRestartBanner shows a "Discard" button alongside "Apply Now";
clicking it drops the pending state without restarting any containers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All host port bindings in docker-compose.yml now use \${VAR:-default} substitution,
driven by the .env file generated by ip_utils.write_env_file(). Changing a port in
Settings triggers a per-container pending-restart banner so only the affected container
is restarted on Apply.
- ip_utils: add PORT_DEFAULTS, PORT_ENV_VAR_NAMES, PORT_TO_CONTAINERS; extend
write_env_file() to accept optional ports dict and write all port env vars
- docker-compose: convert all hardcoded port bindings to \${VAR:-default} form
- app.py: add _collect_service_ports helper; detect port changes in update_config,
write updated .env and call _set_pending_restart with specific container list;
update _set_pending_restart to merge/accumulate pending state with containers list;
update apply_pending_config to use --no-deps <service> for targeted restarts
- config_manager: add submission_port, webmail_port to email schema; add manager_port
to files schema
- Settings.jsx: make all email/files ports editable, add submission_port, webmail_port,
manager_port fields; update stale identity note
- tests: 8 new tests for PORT_DEFAULTS, PORT_ENV_VAR_NAMES, and port override in write_env_file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When ip_range changes, a persistent amber banner appears at the top of
every page showing what changed and a "Apply Now" button. Clicking it
shows a confirmation modal ("containers will restart briefly"), then
calls POST /api/config/apply which runs docker compose up -d from inside
the API container — no manual make start needed.
Backend:
- _set_pending_restart() / _clear_pending_restart() helpers track state
in config_manager so it survives page refresh
- GET /api/config/pending returns { needs_restart, changed_at, changes }
- POST /api/config/apply runs docker compose up -d via the mounted
docker.sock, using the project working_dir label to resolve host paths
- docker-compose.yml mounts docker-compose.yml itself read-only into
the API container so docker compose can read it from inside
Frontend (App.jsx):
- Polls /api/config/pending every 5 s alongside the health check
- PendingRestartBanner component with confirmation modal
- Optimistically clears banner on Apply click; API and containers
restart in the background
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Site-to-site WireGuard tunnels between PIC cells with automatic DNS forwarding.
Each cell generates an invite JSON (public key, endpoint, VPN subnet, DNS IP,
domain); the remote cell imports it to establish a bidirectional tunnel and
CoreDNS forwarding block so each cell's domain resolves across the mesh.
Backend:
- CellLinkManager: invite generation, add/remove connections, live WireGuard
handshake status; stores links in data/cell_links.json
- WireGuardManager: add_cell_peer() accepts subnet CIDRs (not /32) and an
optional endpoint for site-to-site peers; _read_iface_field() reads port,
address, and network directly from wg0.conf at runtime instead of constants
- NetworkManager: add/remove CoreDNS forwarding blocks per remote cell domain
- app.py: /api/cells/* routes; _next_peer_ip() derives VPN range from
configured address so peer allocation follows any address change
Frontend:
- CellNetwork page: invite panel (JSON + QR), connect form (paste JSON),
connected cells list (green/red status, disconnect button)
- App.jsx: Cell Network nav entry and route
Tests: 25 new tests across test_wireguard_manager, test_network_manager,
test_cell_link_manager (263 total)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- wireguard_manager: _get_configured_port/address/network() read from wg0.conf
instead of module-level constants; get_split_tunnel_ips() derives VPN network
from configured Address; get_server_config() returns configured port, dns_ip,
split_tunnel_ips, vpn_network
- add_peer() and get_peer_config() use configured port (not hardcoded 51820)
- _next_peer_ip() derives subnet from wireguard_manager._get_configured_address()
so new peers are allocated IPs from the correct VPN range after address change
- refresh-ip and check-port API endpoints return configured port, not 51820
- PUT /api/config: when wireguard port/address changes, all peers are marked
config_needs_reinstall so users know to re-download tunnel configs
- get_peer_config endpoint: uses configured split tunnel IPs (not hardcoded)
Frontend:
- Peers.jsx: SERVICES domains use live domain from ConfigContext; generateConfig()
uses serverConf.dns_ip and serverConf.split_tunnel_ips; vpn_network shown in
peer-access description; DNS hint uses live domain; server config loaded at
mount time so it is available without re-fetching on every peer action;
handleUpdatePeer uses /32 for server-side AllowedIPs (was incorrectly using
full/split tunnel CIDRs which the backend rejects)
- WireGuard.jsx: generateWireGuardConfig() uses serverConfig.dns_ip,
split_tunnel_ips from server-config API; split-tunnel description shows
live IPs
Tests: 9 new tests in TestWireGuardConfigReads verify all config reads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dashboard: SERVICES array (Cell Home, Calendar, Files, Webmail) now builds
URLs from useConfig() domain + cell_name instead of hardcoded 'mycell.cell'
- NetworkServices: imports useConfig() and cellAPI; shows current DNS zone and
DHCP range in page header; fetches service_configs.network to display
configured DHCP range and NTP servers alongside live operational data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Changes:
- ConfigContext.jsx: React context that loads /api/config once; exposes domain,
cell_name, refresh() — wraps entire app in App.jsx
- Email/Calendar/Files pages: replace hardcoded 'mail.cell', 'calendar.cell',
'files.cell', 'webdav.cell' with domain from ConfigContext; hostname updates
immediately after Settings save (refreshConfig() called on save)
- /api/status: cell_name and domain now read from stored _identity in config_manager,
not hardcoded 'personal-internet-cell' / 'cell.local'
- network_manager.apply_cell_name(old, new): updates hostname A-record in primary
zone file and reloads CoreDNS; called from PUT /api/config when cell_name changes
- Old identity captured before save so apply_cell_name gets the correct old value
- Settings EmailForm: smtp/imap ports are read-only with note (docker-compose.yml level)
- Settings FilesForm: port is read-only with note (Caddy proxies on 80 externally)
- Settings CalendarForm: port labeled "Internal port; clients use 80 via Caddy"
Tests added:
- test_apply_cell_name_renames_host_record
- test_apply_cell_name_noop_when_same
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each service manager now has apply_config() that writes to the actual config:
- network: dhcp_range → dnsmasq.conf (reload cell-dhcp), ntp_servers → chrony.conf
(restart cell-ntp), domain → dnsmasq.conf domain= line
- email: domain → mailserver.env OVERRIDE_HOSTNAME + POSTMASTER_ADDRESS,
restart cell-mail
- wireguard: port/address/private_key → wg0.conf ListenPort/Address/PrivateKey,
restart cell-wireguard
- calendar: port → radicale config hosts=, restart cell-radicale
PUT /api/config now calls apply_config() after persisting JSON, and returns
{restarted: [...], warnings: [...]} so Settings UI can show which containers
were restarted. _restart_container() helper added to BaseServiceManager.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PUT /api/config now calls service_manager.update_config() for each service
so changes write to the service's own config file, not just cell_config.json
- email_manager.get_status() now reads smtp_port/imap_port/domain from its
config file (defaults: 587/993/cell.local) and includes them in the response
- calendar_manager.get_status() includes configured port (default 5232)
- file_manager.get_status() uses configured port from service config
- Email.jsx reads imap_port/smtp_port from API status instead of hardcoding
- Settings service sections show "port changes require container restart" note
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.py:
- Alert logic now checks status.running (container up/down) instead of healthy
(which requires connectivity tests) — services are only alerted when actually down
- Add POST /api/health/history/clear endpoint to reset history + alert counters
log_manager.py:
- get_all_log_file_infos: include rotated backup files (*.log.1, *.log.2 ...) in listing,
marked with backup=true so UI can dim them and hide rotate button
api.js: add monitoringAPI.clearHealthHistory
Logs page:
- Health History: add Clear button with confirmation
- File panel: show full filename (including .log.1 backups), explain host path and naming,
hide rotate button for backup files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Service manager fixes (connectivity tests):
- email_manager: replace telnet with socket.create_connection for SMTP/IMAP;
replace nslookup with socket.getaddrinfo for DNS; exclude unconfigured domain
from success (email healthy=False now correctly means ports refused, not missing domain)
- calendar_manager: replace localhost:5232 with cell-radicale:5232;
fix database check to test dir writability instead of file existence (files created on demand)
- file_manager: replace localhost:8080 with cell-webdav:80; add top-level success key
- network_manager: replace nslookup with socket.getaddrinfo;
add success key to dhcp_test and ntp_test return values
- routing_manager: exclude iptables_access from success
(iptables runs in cell-wireguard, not API container)
- wireguard_manager: add success key to no-arg test_connectivity result
Health history UI:
- SvcCol reads data?.status?.running || data?.status?.status — handles nested health check shape
Result: network/wireguard/calendar/files/routing/vault all healthy=True.
Email healthy=False is correct — mail server needs ≥1 account before Dovecot starts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker-compose.yml:
- Add json-file logging driver (max-size: 10m, max-file: 5) to all 13 containers
- Docker now owns container stdout/stderr rotation automatically
- Add ./data/logs:/app/api/data/logs volume to API — service logs now persist across restarts
log_manager.py:
- Remove container log collection hack (Docker handles it natively)
- Add set_service_level(service, level) — change log level at runtime without restart
- Add get_service_levels() — return current per-service levels
- Simplify get_all_log_file_infos to return only service log files
app.py:
- Add GET /api/logs/verbosity — return current per-service log levels
- Add PUT /api/logs/verbosity — update levels at runtime, persist to config/log_levels.json
- Load persisted log level overrides at startup from log_levels.json
- Simplify rotate endpoint (service logs only, container logs owned by Docker)
wireguard_manager.py:
- get_keys(): return empty strings if key files don't exist (prevents get_status crash
when wg0.conf is missing at startup and falls through to generate_config)
Logs page (4 tabs):
- API Service Logs: structured JSON logs from Python managers, with search/filter/rotate panel
- Container Logs: live docker logs (read via existing /api/containers/<name>/logs endpoint)
- Verbosity Config: per-service level dropdowns, apply immediately + persist
- Health History: existing health poll table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- log_manager: add collect_container_logs (appends docker logs to container_<name>.log),
get_container_log_lines, rotate_container_log, get_all_log_file_infos
- app.py: new endpoints /api/logs/files (all log file sizes), /api/logs/containers/<name>
(collect+return stored container logs); rotate endpoint now handles both service and container logs
- Logs page: split into API Service Logs tab (python manager logs) and Container Logs tab
(persistent docker stdout/stderr); Statistics tab shows both kinds with per-row rotate;
each tab has a description explaining what it shows and where files live
- wireguard_manager: test_connectivity peer_ip=None guard (already in previous commit, now rebuilt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WireGuardManager.test_connectivity: make peer_ip optional so health_check
can call it without args (was logging ERROR on every health poll)
- Logs page: add ALL option to service selector (uses search across all services)
- Logs page: show service tag on each log line when in ALL/search mode
- Logs page: require window.confirm before rotating logs to prevent accidental data loss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added info banners on Firewall, Peer Routes, and Live iptables tabs
explaining that stored rules (NAT/Firewall/Peer Routes forms) and live
rules (pic-peer-* from Peers page, PostUp from wg0.conf) are separate
by design — Live iptables shows everything, each form tab shows only
what it manages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server-side access control:
- firewall_manager.py: per-peer iptables FORWARD rules in WireGuard container;
virtual IPs on Caddy (172.20.0.21-24) for per-service DROP/ACCEPT targeting
- CoreDNS Corefile regenerated with ACL blocks for blocked services per peer
- POST /api/wireguard/apply-enforcement re-applies rules after WireGuard restart;
wg0.conf PostUp calls it via curl so rules restore automatically on container start
WireGuard fixes:
- _syncconf uses `wg set peer` instead of `wg syncconf` to avoid resetting ListenPort
- add_peer validates AllowedIPs must be /32 — rejects full/split tunnel CIDRs that
would route internet or LAN traffic to that peer
- _config_file() checks for linuxserver wg_confs/ subdirectory first
UI:
- Peers page fetches /api/wireguard/peers/statuses for live handshake data;
status badge now shows real Online/Offline + seconds since last handshake
- IP field removed from Add Peer form (auto-assigned from 10.0.0.0/24)
Tests (246 pass):
- test_firewall_manager.py: 22 tests for ACL generation, iptables rule correctness,
comment tagging, clear_peer_rules filter logic
- test_peer_wg_integration.py: 10 tests for /32 enforcement, IP auto-assignment,
syncconf called on add/remove
- test_wireguard_manager.py: updated to reflect correct IPs and /32 requirement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Peer creation/edit form now configures:
- Tunnel mode: full (0.0.0.0/0) or split (PIC only)
- Per-service access toggles (calendar, files, mail, webdav)
- Peer-to-peer communication toggle
- Optional calendar account creation
- Access capability badges in peer list
Bug fixes:
- DNS in client configs was 8.8.8.8 / 172.20.0.2 — now 172.20.0.3 (CoreDNS)
This was why .cell domains didn't resolve on connected VPN peers
- get_peer_config API uses stored internet_access to set AllowedIPs
- New PUT /api/peers/<name> endpoint with config_changed detection
- POST /api/peers/<name>/clear-reinstall clears reinstall flag after download
- Routing page reads real host routes via /proc/1/net/route (pid: host)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WireGuard default changed to full tunnel (0.0.0.0/0) — all peer traffic
routes through PIC server so internet latency matches server's clean 41ms
- UI tunnel toggle now defaults to Full tunnel
- API /peers/config accepts allowed_ips param so UI toggle wires through
- Routing page reads real host routes via /proc/1/net/route (pid: host)
instead of mock data; shows ens18/192.168.31.1 correctly
- Add iproute2 + util-linux to API Dockerfile
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- check_port_open now checks if wg0 interface is actually listening (via
'wg show wg0') instead of requiring a live peer handshake. This means
the port shows 'Open' whenever WireGuard is running, not only when a
peer has connected recently.
- get_peer_config defaults to split-tunnel AllowedIPs (10.0.0.0/24,
172.20.0.0/16) so VPN clients only route cell service traffic through
the tunnel. Local LAN traffic (192.168.x.x etc.) stays direct, fixing
the 60-120ms penalty when pinging local hosts while on VPN.
- Peer config modal now uses cell DNS (172.20.0.2) so .cell domains
resolve correctly with both split and full tunnel.
- Added split/full tunnel toggle in the peer config modal so users can
download either config variant.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix CoreDNS not loading .cell zones (wrong Corefile path, now uses -conf flag)
- Fix WireGuard server address conflict (172.20.0.1/16 overlapped with Docker
network; changed to 10.0.0.1/24 to eliminate duplicate routes)
- Add SERVERMODE=true and sysctls to WireGuard docker-compose for server mode
- Fix DNS zone file parser to handle 4-field records (name IN type value)
- Add get_dns_records() to NetworkManager; mount data/dns into API container
- Fix peer config endpoint: look up IP/key from registry, use real endpoint
- Add bulk peer statuses endpoint keyed by public_key
- Normalize snake_case API fields to camelCase in WireGuard UI
- Add port check endpoint (checks via live handshake, not unreliable TCP probe)
- Add Caddy virtual hosts for ui/calendar/files/mail .cell domains (HTTP only)
- Fix cell config domain default from cell.local to cell
- Fix Routing Network Config tab (was calling hardcoded localhost:3000)
- Fix DNS records display (record.value not record.ip)
- Move service access guide to top of Dashboard with login hints
- Add /api/routing/setup endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WireGuardManager: get_external_ip() (cached 1h), check_port_open(),
get_server_config() returning public_key + detected endpoint
- API: /api/wireguard/server-config returns real external IP;
/api/wireguard/refresh-ip forces re-detection;
/api/wireguard/peers/config now looks up peer IP + private key from
registry and uses real server endpoint automatically
- Fix doubled port in Endpoint (178.x:51820:51820 → 178.x:51820)
- Fix Address=/32 when peer_ip already has mask
- WebUI nginx: proxy /api/ and /health to cell-api (fixes localhost:3000
hardcode — UI now works from any machine)
- api.js: baseURL='' so all calls go through nginx proxy
- WireGuard page: show Server Endpoint card with external IP, endpoint,
public key, and Refresh IP button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>