wrolpi-bootstrap.sh created controller.yaml on installed systems, which
hid the onboarding wizard. Installed systems now wait for the wizard,
whose repair.sh finishes setup.
"Run repair without external drive" now writes controller.yaml before
starting repair, so the wizard does not return after repair.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Hotspot captive portal: welcome page with the WROLPi's address
Devices that join the hotspot probe a fixed plain-HTTP URL to check for
Internet access. NetworkManager's shared-mode dnsmasq now resolves those
probe hostnames (and `wrolpi`) to the hotspot address via an include the
Controller writes when the hotspot starts, the probes land on the Controller
(port 80), and are redirected to /portal: "This is your WROLPi, its address is
10.42.0.1, open WROLPi / open the Controller".
Only the probe hostnames are hijacked so the hotspot keeps forwarding
everything else when the WROLPi has an uplink. The include is corrected and
the connection re-activated once if NetworkManager picked a non-default
subnet. Writing the include is best-effort; the hotspot starts regardless.
`hotspot.captive_portal` (controller.yaml, default true) toggles the probe
redirects and the include; like the other hotspot settings it applies on the
next hotspot start, so saving while the hotspot is up reports
restart_required and both Controller UIs say so. The status endpoint gains
the hotspot address and portal URL, shown in both UIs. repair.sh only
creates the include directory so a disabled setting survives upgrades.
Native only: Docker has no hotspot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: address review
* React: re-read the hotspot address whenever the HotspotToggle starts or
stops the hotspot, matching the fallback UI's refresh-on-status.
* A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets
the captive portal address correction too.
* The corrective `nmcli connection up` logs a warning on a non-zero exit;
dnsmasq would otherwise keep the old address silently.
* HotspotStatusResponse.captive_portal defaults to true like everywhere else.
* Trim design narrative from the module docstring and React comments.
* The include-before-activate test now observes the include at the moment
of the `nmcli ... hotspot` call instead of inferring from call order.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: never forward any record type for probe hosts
`address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS
record type; dnsmasq forwarded that upstream, Apple's answer aliased to
captive.g.aaplimg.com, and the phone fetched the genuine "Success" from
Apple instead of the portal. Each probe host now also gets `local=`, so
dnsmasq answers every type itself, and the CDN alias is a probe host too.
Observed with dnsmasq query logging on a hotspot with an eth0 uplink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: let acknowledged clients finish so the OS offers "Done"
A phone's sign-in sheet only shows "Done" once the OS re-probes and gets
the genuine success reply. The Controller now remembers each client (by
hotspot IP) that has loaded the portal page and answers its later probes
with the reply each OS expects (Apple "Success" HTML, Android 204, Windows
"Microsoft Connect Test", ...). The set is cleared when the hotspot
starts so a new session shows the page again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: acknowledge on Continue, not on view
iOS fetches the portal page in the background before opening the sheet,
then re-probes after the sheet's own navigation. Acknowledging on view
made that re-probe return "Success" as the sheet's content. Phones only
re-check the network (and offer "Done") after a full-page navigation, so
the welcome page now has a Continue button leading to /portal/continue,
which is the only place a client is acknowledged. The Continue page says
to tap Done and links to WROLPi and the Controller.
Also hijack clients3.google.com (older Android probe host).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* help: bump wrolpi-help to the captive portal and Speed Test docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: comments describe the Continue design; clear acks on stop
Three comments still described acknowledging on view, which c72a7f7b
removed. Acknowledged clients are now also cleared when the hotspot
stops, so a lease reused in a later session sees the portal page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The Hotspot, Bluetooth, Desktop, VNC and Throttle toggles had each been written
out longhand at four layers. Collapse the shared parts:
- useSubsystemToggle() replaces five near-identical hooks. This also fixes a
latent bug: only useDesktop/useVnc restored state when a request failed, so a
failed Bluetooth/Throttle/Hotspot toggle left at null and — since the
status poll sees no change — the toggle stayed disabled and read as
unsupported until it remounted.
- SubsystemToggle renders all five, with the Desktop's stop confirmation and
VNC's desktop gating as declared options rather than bespoke copies.
- The fallback UI's four update*/toggle* pairs become one config-driven
implementation (-100 lines).
- _raise_for_action() centralises the 13 endpoint failure checks, including
VNC's 409-vs-500 split.
Deliberately not shared: the six get_*_status_dict() builders (payloads
genuinely differ) and the per-subsystem mechanisms (nmcli/rfkill/sysfs/systemctl).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a VNC toggle to the Controller (both UIs) that starts/stops whichever VNC
backend is installed: wayvnc for Wayland (Raspberry Pi OS), RealVNC for X11.
Runtime-only, like the Desktop toggle: the unit is never enabled, so VNC does
not return after a reboot. Users who want it permanently on can enable it with
raspi-config.
VNC serves the desktop session, so starting it without a desktop gives clients
a blank screen. The gate lives in the backend, not just the UI, so curl callers
get it too (409, distinct from a systemctl failure). Stopping is never gated so
a running VNC server can't be stranded on with no way to turn it off.
Backend selection deliberately avoids raspi-config's live-compositor probe
(pgrep labwc), which reports X11 — and would pick the wrong backend — whenever
the desktop is stopped. wayvnc also requires its config to exist; that is
checked directly rather than via the unit's ConditionResult, which reports 'no'
for any unit that has never been started.
Extracts _unit_properties()/_systemctl_unit() so Desktop and VNC share one
systemd probe and start/stop path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stopping the display manager left the Pi's monitor black with only a blinking
cursor. The console VT is already in the foreground with a getty and a live
shell on it (verified: /dev/vcs1 holds the login banner and prompt) — the
display manager just hands the display back without the console being
repainted, so nothing is visible.
Activate the console VT, then bounce off another VT to force a modeset and
redraw, and print a short message telling the user the Controller stopped the
desktop and how to start it again.
Best-effort throughout: skipped when no getty is on the console VT, a chvt or
console-write failure never fails the stop, and being stranded on the repaint
VT is reported rather than claimed as success. Verified on 10.0.0.8 hardware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a Desktop toggle to the Controller (both UIs) that starts/stops
display-manager.service. Fail open: the unit is never enabled/disabled,
so the desktop always returns on reboot; users who want it disabled
across reboots can set the default target themselves.
Rename the hotspot and bluetooth actions to reflect their real behavior:
- enable/disable_hotspot -> start/stop_hotspot (/api/hotspot/start|stop)
- enable/disable_bluetooth -> unblock/block_bluetooth (rfkill semantics,
/api/bluetooth/unblock|block)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Probe the selected WiFi device with iw to determine which hotspot
protocols it can host: WPA2 requires AP mode; WPA3 requires SAE in AP
mode (SAE_OFFLOAD_AP, or a mac80211 driver with the CMAC cipher for
software SAE). The Raspberry Pi's brcmfmac supports neither, so Pis
correctly offer WPA2 only (verified against real Pi 4/Pi 5 iw output).
The protocol is saved as hotspot.protocol in controller.yaml.
enable_hotspot() now always sets the Hotspot connection's key-mgmt
explicitly (sae + required PMF for WPA3) so a reused profile cannot
keep a stale protocol, and reverts to WPA2 if the driver rejects SAE.
New GET /api/hotspot/protocols endpoint; protocol dropdowns added to
both the ControllerPage React UI and the fallback controller UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RPi 5 fan connector exposes a tachometer via the pwmfan hwmon
device. Report it as fan_rpm in cpu_stats and show it in both the
Controller fallback UI and the React Status page. Hidden when no fan
is present.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: skip the service stop when the primary mount is already
unmounted (a no-op request must not cause an API outage), and restart
wrolpi-api (best effort) when umount fails since the drive is still
mounted and usable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Support swapping in a backup drive: /api/disks/unmount now accepts the
primary mount (previously rejected as reserved). It stops wrolpi-api so
open files are released, then umounts directly since the reconciler
never manages the primary mount. The /etc/fstab entry is left in place
so a reboot restores the mount.
Both UIs now offer Unmount for the primary mount and warn that the API
service will be stopped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The React ControllerPage partitions services on `service.group`, but
ServiceStatusResponse did not declare the new kind/group fields, so
Pydantic silently stripped them from /api/services and the React UI
rendered one flat, ungrouped table. The fallback UI was unaffected
because it is server-rendered without the response model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /disks/mount wrote every mount into fstab.yaml and waited for the
reconciler — but the reconciler skips /media/wrolpi as a reserved mount
point, so mounting the primary drive from the Disks page could never
succeed and left a phantom fstab.yaml entry behind (visible as
"1 desired, 1 skipped: reserved mount point" in the journal). Mount the
primary drive directly via mount_drive() and persist it to /etc/fstab,
as onboarding does.
Phantom entries are now also invisible to the fstab list endpoint (they
were reported as active persistence) and are removed whenever the
primary mount is mounted, persisted, or un-persisted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Disks page treated /media/wrolpi as fully protected, so a
manually-mounted primary drive could not be persisted from the UI — and
the Persist toggle would have been wrong anyway: it wrote fstab.yaml,
which the wrolpi-mounts reconciler refuses to apply for the primary
mount (RESERVED_MOUNT_POINTS). Route Persist for the primary mount to
the host's /etc/fstab (as onboarding does), report its entry in the
fstab list endpoint, and enable the toggle in both UIs while keeping
Unmount blocked. Mounts outside /media (e.g. /boot/efi, which showed
an Unmount button) are now entirely untouchable System mounts.
Also:
- Run `ntfsfix -d` before mounting ntfs/ntfs3 volumes so the Windows
dirty flag (Fast Startup, unclean unplug) cannot block mounting;
non-fatal and a no-op on clean volumes.
- Add uid/gid fstab options for ntfs/ntfs3, matching mount_drive().
- Log reconcile diagnostics (desired/already-mounted/skipped with
reasons) so an idle wrolpi-mounts run explains itself in the journal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* POST /api/hotspot/settings checks is_docker_mode() directly instead of
string-matching the error message (still 500, matching the sibling
hotspot/bluetooth/throttle endpoints).
* Escape \ ; , " in the WiFi QR code per the WPA QR format.
* Skip the controller.yaml write when a settings update changes nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hotspot was reported as "not supported" on x86 machines (Ubuntu/Debian PCs)
because the Controller only watched the hardcoded default wlan0 device, while
predictable interface naming produces names like wlp2s0. The Settings page
saved hotspot_device to wrolpi.yaml, which the Controller never read.
The Controller now owns hotspot settings in controller.yaml:
* New endpoints: GET/POST /api/hotspot/settings, GET /api/hotspot/devices
(WiFi interfaces discovered via nmcli).
* Legacy hotspot_* values in wrolpi.yaml are migrated into controller.yaml
once on startup, unless controller.yaml already sets them.
* The Hotspot SSID/password/device fields moved from the Settings page to the
Control page next to the Hotspot toggle; the device is now a dropdown of
the WiFi devices found on the machine.
* The Controller fallback UI gets the same hotspot settings form for parity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The /api/disks/unmount endpoint only removed entries from fstab.yaml and
asked the reconciler to unmount, but the reconciler deliberately never
unmounts paths it has not claimed. Foreign mounts (udisks2 desktop
automounts, manual mounts) therefore could not be unmounted from the UI,
failing with a confusing "check journalctl" error.
- Fall back to a direct umount(8) when a reconcile leaves the requested
mount point live.
- Reject unmounting the primary WROLPi drive (/media/wrolpi) with a
clear 400, normalized to catch path traversal.
- Require the normalized mount point to be strictly under /media/
before any direct umount, since the fallback runs as root.
- Show "System" instead of an Unmount button for /media/wrolpi in both
the React UI and the Controller's simplified UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A WROLPi drive can move between hosts and across deployment styles
(Portable, RPi, Debian). /etc/fstab is host-specific and on the Portable
image it's part of a read-only squashfs, so persisting secondary mounts
there was both wrong-host and lost-on-reboot. This change makes
/media/wrolpi/config/fstab.yaml the single source of truth for WROLPi-
managed mounts and introduces a new wrolpi-mounts.service that reconciles
live state to match.
Architecture:
- The Controller writes intent (fstab.yaml) but never calls mount(8) or
umount(8) itself. Every mutation triggers `systemctl restart
wrolpi-mounts.service`; the Controller then verifies the outcome via
the same MountExecutor abstraction the service uses.
- wrolpi-mounts.service runs a thin entry point that constructs a real
Reconciler with a SubprocessMountExecutor and calls apply(). The
reconciler mounts what's in fstab.yaml but missing from the live state,
and unmounts paths it previously claimed that are no longer in
fstab.yaml. A managed-set file in /run/wrolpi-mounts/managed prevents
the reconciler from ever touching user-mounted paths it didn't own.
- One-time migration (marker-gated) lifts both /etc/fstab WROLPi entries
and any controller.yaml drives.mounts entries into fstab.yaml.
/etc/fstab is backed up to /etc/fstab.wrolpi-migration.backup;
migrated lines are commented in place with a "migrated to fstab.yaml:"
prefix so a host admin can hand-recover.
Test approach: dependency injection in place of patching. The
MountExecutor protocol has SubprocessMountExecutor (production) and
FakeMountExecutor (in-memory, records calls). The Reconciler accepts an
executor in its constructor, so the 20 reconciler tests + 13 migration
tests + 18 fstab_yaml tests use real files in tmp_path with zero
mock.patch calls. test_disks_api.py drops to mocks only for the
host-touching read helpers; the mount/unmount paths now run the real
Reconciler against the fake executor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce an onboarding system in the Controller that guides users
through selecting and mounting their primary media drive. This is
the fallback UI flow used when the main React app is unavailable.
Key changes:
- Add onboarding API endpoints and schemas (controller/api/onboarding.py)
- Add onboarding logic to detect drives, handle already-mounted drives
(needed for RPi which auto-mounts), and save primary drive selection
to the controller config when no mounts are configured
- Extend the Controller's index.html template with the onboarding UI
- Skip root cert creation until onboarding is complete
- Update first_startup.sh to support the onboarding/repair flow
- Add comprehensive tests for onboarding logic and API