diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..be3dbf2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{sh,bats}] +indent_style = space +indent_size = 4 + +[test_harness.sh] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcc7c7d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts, units and rules must keep LF line endings on every platform. +# Without this, a Windows checkout (core.autocrlf=true) turns every script +# into CRLF and they fail on Linux with "$'\r': command not found". +* text=auto eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69b20df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project uses +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Fixed +- **The hotspot could not start in v1.5.0.** `PrivateTmp=false` together with + `ProtectSystem=strict` made `/tmp` read-only, so `create_ap` could not create its + working directory. `PrivateTmp=true` is back. +- Restored the `CAP_CHOWN` and `CAP_SYS_RESOURCE` capabilities (dnsmasq, and the PAM + session used for desktop notifications) and dropped `RestrictRealtime=true`. +- `create_ap` can write NetworkManager's `unmanaged-devices` setting again + (`ReadWritePaths=-/etc/NetworkManager`). +- Stopping the hotspot waits for `create_ap` to finish its own cleanup. Before, Fiero + deleted `create_ap`'s temp files right away, which could leave `ip_forward` enabled + after the hotspot was off. +- Fiero only manages its own `create_ap`. It no longer deletes other tools' `ap*` + interfaces or `/tmp/create_ap*` directories, and refuses to start when another + `create_ap` already runs on the interface. +- A hotspot that `create_ap` started with `--no-virt` (no `ap*` interface) is no longer + torn down as "failed to start". +- `clients` never listed any device: `iw` prints `signal: -45 [...] dBm`, which the + old pattern did not match. It now also works with the service's private `/tmp`. +- Unplugging the charger while the "Start?" prompt is open now cancels that prompt. +- A failing `iw dev link` (driver reset, card removed) counts as disconnected + instead of looping forever. +- `sudo fiero-hotspot stop` while the service is running now goes through + `systemctl stop`, so the unit state stays correct. +- `help` and `version` work without root, a config file, or `create_ap` installed. +- The installer validates the sudoers rule *before* putting it in place, so a bad rule + can no longer break `sudo` for the whole system. +- The test harness no longer reports PASS when the hotspot start/stop was skipped (it + now exits 2, "incomplete"), and warns when the installed copy differs from the + repository. `audit.sh` no longer says "Zero defects" when checks were skipped. + +### Security +- The WPA passphrase is passed to `create_ap` through a root-only config file instead + of the command line, so local users can no longer read it with `ps`. + +### Changed +- If nobody answers the "Start?" prompt, the hotspot is **not** started anymore. Set + `AUTO_START_ON_TIMEOUT='true'` in `/etc/fiero-hotspot.conf` for the old behaviour. +- The installer refuses to run from a root shell, offers to keep an existing config, + asks which interface to use when there are several, and rejects SSIDs/passphrases + that `create_ap`'s config parser would change (backslashes, surrounding spaces). +- `uninstall.sh --keep-config` keeps `/etc/fiero-hotspot.conf`. + +### Added +- `.gitattributes` (LF line endings on every platform) and `.editorconfig`. +- This changelog. + +## [1.5.0] - 2026-09-23 (not tagged) +- systemd sandbox changes (`PrivateTmp=false`, `RestrictRealtime`, fewer capabilities) + and client lease visibility. + +## [1.4.1] - 2026-09-23 +- See the [GitHub release](https://github.com/Fiero1186/Fiero-Hotspot/releases/tag/v1.4.1). diff --git a/README.md b/README.md index d3e0a2a..2effc05 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fiero Hotspot -![Version](https://img.shields.io/badge/version-v1.3.0-blue) +![Version](https://img.shields.io/badge/version-v1.5.0-blue) ![License](https://img.shields.io/badge/license-GPL--3.0-green) ## Overview @@ -83,31 +83,33 @@ The upstream Wi-Fi connection must be managed by NetworkManager. The daemon uses ``` udev event (AC online/offline) └─> 99-fiero-hotspot.rules - └─> su - -c /usr/local/bin/fiero-prompt - └─> systemctl start/stop fiero-hotspot.service + └─> systemd-run --no-block -- su - -c /usr/local/bin/fiero-prompt + └─> sudo -n systemctl start/stop fiero-hotspot.service └─> /usr/local/bin/fiero-hotspot {start|stop} └─> flock -n /run/fiero-hotspot.lock - └─> create_ap -c + └─> create_ap --config /run/fiero-hotspot/create_ap.conf ``` -The udev rule fires on any `power_supply` `change` event where `ATTR{type}=="Mains"` and `ATTR{online}` is `1` (plugged) or `0` (unplugged). This invokes `fiero-prompt.sh` as the logged-in user via `su`, which presents a desktop notification action prompt and issues `sudo -n systemctl start|stop` accordingly. +The udev rule fires on any `power_supply` `change` event where `ATTR{type}=="Mains"` and `ATTR{online}` is `1` (plugged) or `0` (unplugged). This invokes `fiero-prompt.sh` as the logged-in user via `systemd-run` + `su` (so udev does not kill the long-running prompt), which presents a desktop notification action prompt and issues `sudo -n systemctl start|stop` accordingly. ### Signal Handling -`fiero-hotspot.sh` traps `EXIT`, `INT`, and `TERM`. The `cleanup` function: +Fiero only manages the `create_ap` instance it started. Its state lives in `/run/fiero-hotspot/` (mode `0700`, created by systemd's `RuntimeDirectory=`): the `create_ap` PID, the `create_ap` config directory, and the AP interface `create_ap` actually uses (`ap0`, or the physical card when `create_ap` falls back to `--no-virt`). -1. Runs `create_ap --stop ` -2. Kills the background `create_ap` PID -3. Runs `pkill -f "create_ap.*"` as a fallback -4. Iterates over virtual `ap*` interfaces (`iw dev`) and tears them down with `ip link set dev down`, `iw dev del`, and `ip link delete ` -5. Removes stale `create_ap*` temp files from `/tmp` -6. Deliberately skips deleting `p2p-dev-` to prevent iwlwifi firmware crashes +`fiero-hotspot.sh start` traps `EXIT`, `INT`, and `TERM`. Stopping (by the trap, by `fiero-hotspot stop`, or on a channel change): -Cleanup is idempotent. The `SKIP_CLEANUP` flag prevents redundant teardown when the process exits due to a lock conflict or an already-running instance. +1. Sends `USR1` to Fiero's own `create_ap` (its clean-exit signal, same as `create_ap --stop`) and **waits up to 15 seconds** for it to exit. `create_ap`'s own cleanup restores `ip_forward`, removes its `iptables` rules and undoes its NetworkManager changes, so it must be allowed to finish first. +2. Only if `create_ap` hangs: kills it, stops the `hostapd`/`dnsmasq` belonging to *that* instance (matched by its config directory), and restores the saved `ip_forward` value if no other `create_ap` is running. +3. Removes a leftover virtual `apN` interface of Fiero's own instance. The physical card and other tools' interfaces are never touched. +4. Deliberately skips deleting `p2p-dev-` to prevent iwlwifi firmware crashes. + +Hotspots started by other tools (for example the linux-wifi-hotspot GUI) are left alone: if a `create_ap` is already running on the interface, `start` exits with a message instead of killing it. Running `sudo fiero-hotspot stop` by hand while the systemd service is active simply runs `systemctl stop fiero-hotspot.service`. ### IPC & Prompts -Root-to-user notification routing is handled via D-Bus. `fiero-prompt.sh` sets `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus` and invokes `notify-send` to present interactive actions (`Start`/`Ignore`/`Keep Running`). A timestamp-based cooldown file (`/run/user//fiero-prompt.state`) debounces rapid udev event clusters (11-second window). A separate `flock` on `/run/user//fiero-prompt.lock` prevents concurrent prompt invocations. +Root-to-user notification routing is handled via D-Bus. `fiero-prompt.sh` sets `DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus` and invokes `notify-send` to present interactive actions (`Start`/`Ignore`/`Keep Running`). A timestamp-based cooldown file (`/run/user//fiero-prompt.state`) debounces rapid udev event clusters (11-second window). A `flock` on `/run/user//fiero-prompt.lock` serialises that debounce step (it is not held while the notification is on screen). If the charger state flips while a prompt is still open, the stale prompt is cancelled. + +If nobody answers the "Start?" prompt, **nothing is started** unless `AUTO_START_ON_TIMEOUT='true'` is set in the config: starting a Wi-Fi network should need a clear "yes". If nobody answers the "Stop?" prompt after unplugging, the hotspot is stopped (the safe choice on battery). ## Installation & Removal @@ -123,11 +125,11 @@ The installer: 2. Detects the Wi-Fi interface and AC power supply via `/sys/class/power_supply/` 3. Parses supported channels from `iw phy info`, excluding restricted frequencies 4. Prompts for SSID and password (min 8 chars, WPA2 requirement) -5. Writes configuration to `/etc/fiero-hotspot.conf` (mode `640`, owned `root:`; the runtime validators also accept mode `600`) +5. Writes configuration to `/etc/fiero-hotspot.conf` (on a re-install you can keep the existing one) (mode `640`, owned `root:`; the runtime validators also accept mode `600`) 6. Installs binaries to `/usr/local/bin/fiero-hotspot` and `/usr/local/bin/fiero-prompt` 7. Installs the systemd unit to `/etc/systemd/system/fiero-hotspot.service` 8. Installs the udev rule to `/etc/udev/rules.d/99-fiero-hotspot.rules` -9. Creates a sudoers drop-in at `/etc/sudoers.d/fiero-hotspot` granting the target user passwordless `systemctl start|stop` for `fiero-hotspot.service` +9. Creates a sudoers drop-in at `/etc/sudoers.d/fiero-hotspot` granting the target user passwordless `systemctl start|stop` for `fiero-hotspot.service`. The rule is validated with `visudo -c` *before* it is put in place, so a bad rule can never break `sudo`. 10. Reloads udev rules and systemd daemon ### Configuration @@ -144,6 +146,7 @@ File: `/etc/fiero-hotspot.conf` | `TARGET_USER` | User for notification routing | | `TARGET_UID` | UID of `TARGET_USER` | | `AUTO_PROMPT` | `true` for automatic D-Bus prompt on AC events, `false` for CLI-only control | +| `AUTO_START_ON_TIMEOUT` | `true` to start the hotspot when the "Start?" prompt is not answered; default `false` | ### Enable & Start @@ -195,7 +198,7 @@ fiero-hotspot help # Print usage menu (also: -h, --help, or no ar 11:22:33:44:55:66 -62 dBm 192.168.12.11 - ``` -IP and hostname are resolved from `/var/lib/misc/dnsmasq.leases` (and any `create_ap` runtime lease files). Fields default to `-` when unavailable. +IP and hostname are resolved from the lease file of Fiero's `create_ap` instance (read through `/proc//root`, because the service runs with a private `/tmp`) and `/var/lib/misc/dnsmasq.leases`. Fields default to `-` when unavailable. ### Uninstall @@ -203,7 +206,7 @@ IP and hostname are resolved from `/var/lib/misc/dnsmasq.leases` (and any `creat sudo ./uninstall.sh ``` -Removes all installed files, stops/disables the service, and reloads udev/systemd. +Removes all installed files, stops/disables the service, and reloads udev/systemd. Use `sudo ./uninstall.sh --keep-config` to keep `/etc/fiero-hotspot.conf` for a later re-install. ## Diagnostics & Test Suite @@ -217,7 +220,7 @@ Requires root and a valid `/etc/fiero-hotspot.conf` with `INTERFACE`, `TARGET_US ### Test Phases -The harness executes **104 edge-case tests** across all phases: +The harness executes over 100 edge-case checks across all phases. Phase 9 tests the *installed* copy (`/usr/local/bin/fiero-hotspot`) and fails if it differs from the repository copy - re-run `install.sh` after changing the code. | Phase | Tests | Description | |-------|-------|-------------| @@ -269,7 +272,7 @@ All output is logged to `./test-logs-/`: summary.txt ``` -Exit code `0` = all tests passed. Exit code `1` = hard failure (orphan leak, interface leak, dmesg crash, edge-case failure, or lifecycle timeout). +Exit code `0` = all tests passed, including a real start/stop of the hotspot. Exit code `1` = hard failure (orphan leak, interface leak, dmesg crash, edge-case failure, or lifecycle timeout). Exit code `2` = **incomplete**: no failures, but the hotspot lifecycle was skipped because upstream Wi-Fi was on an unsupported channel - connect to a supported channel and re-run. ### Reporting Issues @@ -287,7 +290,11 @@ Attach `summary.txt`, `lspci.txt`, and `iw_list.txt` along with your `/etc/fiero ### Runtime Credential Visibility (Process Table) -`create_ap` accepts the WPA2 passphrase as a command-line argument. On standard multi-user systems (without `procfs` mounted with `hidepid=2`), the cleartext passphrase is visible in the process table (`ps aux` / `/proc/$PID/cmdline`) to any unprivileged local user while the hotspot is active. For environments where this is a concern, mount `/proc` with `hidepid=2` and grant access only to specific UIDs. +The passphrase is handed to `create_ap` through a root-only (`0600`) config file, `/run/fiero-hotspot/create_ap.conf`, which is deleted as soon as the AP is up, so it does not appear in `ps` or `/proc//cmdline`. + +`create_ap` reads that file with `read` (without `-r`), which drops backslashes and trims leading/trailing spaces. The installer therefore rejects such SSIDs/passphrases. For an older config that contains one, Fiero falls back to passing it on the command line (visible to local users) and logs a warning; mount `/proc` with `hidepid=2` if that matters on a shared machine. + +The passphrase is stored in `/etc/fiero-hotspot.conf`, which the target user can read (mode `640 root:`). ### Single-Radio Throughput Penalty @@ -325,7 +332,7 @@ Requires a physical wireless adapter exposing an `nl80211` interface capable of ## Systemd Hardening Rationale -The systemd service achieves a **5.7 MEDIUM** exposure rating (`systemd-analyze security fiero-hotspot.service`), down from 8.2 EXPOSED in v1.2.0. The remaining 5.7 score is the architectural floor for this release — three directives are intentionally omitted because they break essential functionality: +The systemd service achieves a **4.2 OK** exposure rating (`systemd-analyze security fiero-hotspot.service`), down from 5.7 MEDIUM in v1.4.1 and 8.2 EXPOSED in v1.2.0. `PrivateTmp=true` is required: with `ProtectSystem=strict` the rest of the filesystem is read-only, and `create_ap` needs a writable `/tmp`. `ReadWritePaths=-/etc/NetworkManager` lets `create_ap` mark its virtual interface as unmanaged. Three directives are intentionally omitted because they break essential functionality: ### NoNewPrivileges=true (omitted) @@ -342,7 +349,7 @@ Mounts `/proc/sys` read-only. `create_ap` writes to `net.ipv4.ip_forward` during ### Why Root Execution and CAP_NET_ADMIN Are Required - **Root**: NAT routing via `iptables` requires root privileges. The service runs as root and uses `sudo -u $TARGET_USER` to drop privileges for user-facing operations. -- **CAP_NET_ADMIN**: Required for `iptables` rule management and `create_ap` network namespace operations. This capability cannot be replaced with user-level alternatives for NAT. +- **CAP_NET_ADMIN**: Required for `iptables` rule management and `hostapd`/interface configuration. This capability cannot be replaced with user-level alternatives for NAT. - **D-Bus SASL**: Root cannot connect directly to `/run/user/$UID/bus` because D-Bus validates the connecting UID via SASL EXTERNAL authentication. The daemon routes through `sudo -u $TARGET_USER` to match the socket owner's UID. ## Developer Notes @@ -368,13 +375,13 @@ I will do occasional updates to this project whenever I am free to do so, but pl ### Built with AI, Verified with Ironclad Constraints The implementation was vibe-coded using LLMs via OpenCode, under strict systems engineering constraints: -- **Zero Blind Trust:** Every component—from root-to-user D-Bus session routing down to udev power triggers—was subjected to a strict 104-pass bash test harness (`test_harness.sh`). +- **Zero Blind Trust:** Every component—from root-to-user D-Bus session routing down to udev power triggers—was subjected to a strict bash test harness (`test_harness.sh`). - **Zero Process Leakage:** Background workers, `hostapd`, and `dnsmasq` instances are tracked and reaped on `SIGTERM`/`EXIT` to prevent zombie interfaces and memory leaks. - **Race-Condition Safety:** Concurrency is locked down via `flock` file descriptors to guarantee idempotent execution even during erratic AC power plug/unplug events. - **Sandboxed Execution:** Hardened systemd unit isolation (`ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp=true`). `ProtectHome=read-only` keeps `/home` and `/root` write-protected while unmasking `/run/user`, allowing the daemon to access the user session's D-Bus socket for desktop notifications. - **Live USB Boot testing:** Tested in a live boot environment (Arch-Based Garuda Linux iso) -AI handled the rapid boilerplate; strict verification and POSIX compliance rules kept the codebase production-grade. But the idea was fully mine. +AI handled the rapid boilerplate; strict verification and ShellCheck rules kept the codebase production-grade. But the idea was fully mine. ## License diff --git a/SECURITY.md b/SECURITY.md index 2cd2c70..dd00938 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | |---------|-----------| -| 1.3.x | Yes | -| < 1.3 | No | +| 1.5.x | Yes | +| < 1.5 | No | ## Reporting a Vulnerability @@ -15,16 +15,22 @@ Use [GitHub Private Vulnerability Reporting](https://github.com/Fiero1186/Fiero- The following are documented security-relevant design decisions, not bugs: -### Plaintext WPA2 Passphrase in Process Table +### WPA2 Passphrase Handling -`create_ap` receives the WPA2 passphrase as a command-line argument. On standard multi-user systems (without `procfs` mounted with `hidepid=2`), the cleartext passphrase is visible in `/proc/$PID/cmdline` to any local user while the hotspot is active. +The passphrase is stored in `/etc/fiero-hotspot.conf` (mode `640 root:`), so the target user can read it. -**Mitigation**: Mount `/proc` with `hidepid=2` and grant access only to specific UIDs, or use single-user setups where no untrusted users have local access. +At runtime it is handed to `create_ap` through `/run/fiero-hotspot/create_ap.conf` (mode `0600`, root only), which is deleted as soon as the AP is up. It does not appear in the process table. + +**Exception**: `create_ap` parses its config file with `read` without `-r`, which drops backslashes and trims surrounding spaces. The installer rejects such passphrases. If an older config contains one, Fiero falls back to passing it as a command-line argument (visible in `/proc/$PID/cmdline` to local users while the hotspot is active) and logs a warning. Mitigation: re-run `install.sh` with a different passphrase, or mount `/proc` with `hidepid=2`. ### Root Daemon Requirement -The service runs as root to perform NAT routing via `iptables` and network configuration. This results in a systemd exposure score of **5.7 MEDIUM**. The daemon uses `sudo -u $TARGET_USER` for user-facing operations (desktop notifications) and drops capabilities where possible via `CapabilityBoundingSet`. +The service runs as root to perform NAT routing via `iptables` and network configuration. With the systemd sandboxing in `fiero-hotspot.service` it has an exposure score of **4.2 OK** (`systemd-analyze security fiero-hotspot.service`). The daemon uses `sudo -u $TARGET_USER` for user-facing operations (desktop notifications) and limits capabilities via `CapabilityBoundingSet`. ### Config File Sourcing -`/etc/fiero-hotspot.conf` contains the WPA2 passphrase and is sourced as root by both `fiero-hotspot.sh` and `fiero-prompt.sh`. File permissions are enforced at `640 root:`. The runtime validators also accept mode `600`. The file must never be world-readable. +`/etc/fiero-hotspot.conf` is sourced as root by `fiero-hotspot.sh` and as the target user by `fiero-prompt.sh`. Both refuse to source it unless it is owned by root with mode `640` or `600`. It must never be world-readable or writable by anyone but root. + +### sudoers Drop-in + +`/etc/sudoers.d/fiero-hotspot` lets the target user run exactly `systemctl start fiero-hotspot.service` and `systemctl stop fiero-hotspot.service` without a password. The installer validates the rule with `visudo -c` before putting it in place. diff --git a/audit.sh b/audit.sh index 715a48e..bec49d2 100755 --- a/audit.sh +++ b/audit.sh @@ -2,6 +2,7 @@ set -u FAILURES=0 +SKIPPED=() echo "=== 1. Shell Syntax Verification (bash -n) ===" while IFS= read -r -d '' script; do @@ -24,7 +25,8 @@ if command -v shellcheck >/dev/null 2>&1; then FAILURES=$((FAILURES + 1)) fi else - echo "[WARN] shellcheck not installed." + echo "[SKIP] shellcheck not installed." + SKIPPED+=("shellcheck") fi echo -e "\n=== 3. Systemd Unit Sandboxing (systemd-analyze) ===" @@ -41,14 +43,19 @@ echo -e "\n=== 4. Secret & Token Detection (gitleaks) ===" if command -v gitleaks >/dev/null 2>&1; then gitleaks detect --no-git --verbose || FAILURES=$((FAILURES + 1)) else - echo "[INFO] gitleaks not installed." + echo "[SKIP] gitleaks not installed." + SKIPPED+=("gitleaks") fi echo -e "\n----------------------------------------" -if [ "$FAILURES" -eq 0 ]; then - echo "Repository audit clean. Zero defects found." - exit 0 -else +if [ "$FAILURES" -ne 0 ]; then echo "Audit finished with $FAILURES failure(s)." exit 1 +elif [ "${#SKIPPED[@]}" -ne 0 ]; then + # Never report "clean" for checks that did not run. + echo "Audit INCOMPLETE: no failures, but these checks were skipped: ${SKIPPED[*]}" + exit 2 +else + echo "Repository audit clean. Zero defects found." + exit 0 fi diff --git a/fiero-hotspot.conf.example b/fiero-hotspot.conf.example index da582c9..5eaf494 100644 --- a/fiero-hotspot.conf.example +++ b/fiero-hotspot.conf.example @@ -21,3 +21,7 @@ TARGET_UID='1000' # Automatic prompt behavior ('true' for D-Bus notification prompt on AC, 'false' for CLI-only control) AUTO_PROMPT='true' + +# If nobody answers the "Start?" prompt: 'true' starts the hotspot anyway, +# 'false' (default) does nothing. Starting a Wi-Fi network should need a clear yes. +AUTO_START_ON_TIMEOUT='false' diff --git a/fiero-hotspot.service b/fiero-hotspot.service index 4185173..2f5f239 100644 --- a/fiero-hotspot.service +++ b/fiero-hotspot.service @@ -5,7 +5,6 @@ Wants=network.target [Service] Type=simple -PrivateTmp=false SyslogIdentifier=fiero-hotspot StandardOutput=journal StandardError=journal @@ -14,12 +13,26 @@ ExecStop=/usr/local/bin/fiero-hotspot stop TimeoutStopSec=30s Restart=no +# /run/fiero-hotspot (mode 0700) holds the create_ap config file (with the +# passphrase), the create_ap PID and the AP interface name. systemd creates it +# on start and removes it on stop. +RuntimeDirectory=fiero-hotspot +RuntimeDirectoryMode=0700 + # Hardening +# ProtectSystem=strict makes the whole filesystem read-only. /tmp is only +# writable when PrivateTmp=true, and create_ap needs it for +# `mktemp -d /tmp/create_ap.*`. The private /tmp is also wiped automatically +# when the service stops, so no stale create_ap directories survive a crash. +PrivateTmp=true +ProtectSystem=strict # ProtectHome=read-only locks /home and /root; D-Bus session socket access # under /run/user/$UID/bus is restored via ReadWritePaths=/run. ProtectHome=read-only -ProtectSystem=strict -ReadWritePaths=/run /var/log /proc/sys/net/ipv4 +# create_ap writes unmanaged-devices= into NetworkManager.conf so that +# NetworkManager leaves the virtual AP interface alone. The "-" prefix keeps +# the unit working on systems without /etc/NetworkManager. +ReadWritePaths=/run /var/log /proc/sys/net/ipv4 -/etc/NetworkManager RestrictNamespaces=true ProtectKernelModules=true ProtectKernelLogs=true @@ -32,16 +45,19 @@ SystemCallArchitectures=native UMask=0077 RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_PACKET ProtectControlGroups=true -RestrictRealtime=true SystemCallFilter=~@clock @debug @module @mount @obsolete @cpu-emulation @raw-io @reboot @swap # Intentionally omitted hardening directives: # - NoNewPrivileges=true: breaks PAM session for sudo -u $TARGET_USER notify-send +# - RestrictRealtime=true: conflicts with PAM limits applied by sudo for the target user # - ProtectKernelTunables=true: blocks create_ap from writing net.ipv4.ip_forward # See README.md "Systemd Hardening Rationale" for architectural justification. -# Capabilities required for NAT routing and PAM session privilege dropping -CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_KILL CAP_DAC_OVERRIDE CAP_AUDIT_WRITE +# Capabilities required for NAT routing (iptables, hostapd), dnsmasq +# (bind :53/:67, drop to nobody, chown lease/pid files) and the PAM session +# opened by `sudo -u $TARGET_USER notify-send` (pam_limits needs +# CAP_SYS_RESOURCE, pam_loginuid/audit needs CAP_AUDIT_WRITE). +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_RESOURCE CAP_CHOWN CAP_AUDIT_WRITE CAP_KILL CAP_SETUID CAP_SETGID CAP_DAC_OVERRIDE [Install] WantedBy=multi-user.target diff --git a/fiero-hotspot.sh b/fiero-hotspot.sh index 4649bb5..2aef993 100644 --- a/fiero-hotspot.sh +++ b/fiero-hotspot.sh @@ -19,15 +19,27 @@ set -u PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH -SHUTDOWN_LOCK="/run/fiero-shutting-down.lock" -UPSTREAM_GRACE=15 +VERSION="1.5.0" CONFIG_FILE="/etc/fiero-hotspot.conf" +LOCK_FILE="/run/fiero-hotspot.lock" -VERSION="1.5.0" +# Per-instance state. Created by systemd (RuntimeDirectory=) or by us when +# running in the foreground. Only root can read it (mode 0700). +RUN_DIR="/run/fiero-hotspot" +PID_FILE="$RUN_DIR/create_ap.pid" +CONFDIR_FILE="$RUN_DIR/confdir" +IFACE_FILE="$RUN_DIR/ap_iface" +STOP_FLAG="$RUN_DIR/stopping" +AP_CONF="$RUN_DIR/create_ap.conf" + +UPSTREAM_GRACE=15 +AP_START_TIMEOUT=8 +AP_STOP_TIMEOUT=15 # --- Utility Functions --- escape_regex() { + # shellcheck disable=SC2016 # the sed expression is meant to be literal printf '%s' "$1" | sed 's/[][\.|$(){}?+*^\\-]/\\&/g' } @@ -88,36 +100,43 @@ notify() { /usr/bin/notify-send -a "Fiero Hotspot" "Hotspot" "$msg" --icon=network-wireless 2>/dev/null || true } -for cmd in iw pgrep pkill create_ap; do - if ! command -v "$cmd" >/dev/null 2>&1; then - log "ERR" "Required command not found: $cmd. Run install.sh first." +# --- Configuration --- +# Only commands that touch the hotspot load the config; help and version work +# for any user, even before create_ap is installed. +load_config() { + local cmd conf_owner conf_perms + for cmd in iw pgrep pkill create_ap; do + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERR" "Required command not found: $cmd. Run install.sh first." + exit 1 + fi + done + + if [ -f "$CONFIG_FILE" ]; then + conf_owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null) + conf_perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null) + if [ "$conf_owner" != "root" ] || { [ "$conf_perms" != "640" ] && [ "$conf_perms" != "600" ]; }; then + log "ERR" "Config file $CONFIG_FILE has unsafe permissions ($conf_perms, owner=$conf_owner). Expected 600 or 640 root:*. Refusing to source." + exit 1 + fi + # shellcheck disable=SC1090 + source "$CONFIG_FILE" + else + log "ERR" "Config file not found at $CONFIG_FILE. Run install.sh first." exit 1 fi -done -if [ -f "$CONFIG_FILE" ]; then - conf_owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null) - conf_perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null) - if [ "$conf_owner" != "root" ] || { [ "$conf_perms" != "640" ] && [ "$conf_perms" != "600" ]; }; then - log "ERR" "Config file $CONFIG_FILE has unsafe permissions ($conf_perms, owner=$conf_owner). Expected 600 or 640 root:*. Refusing to source." + if [ -z "${SSID:-}" ] || [ -z "${PASSWORD:-}" ] || [ -z "${INTERFACE:-}" ]; then + log "ERR" "Config file is missing required values (SSID, PASSWORD, INTERFACE)." exit 1 fi - source "$CONFIG_FILE" -else - log "ERR" "Config file not found at $CONFIG_FILE. Run install.sh first." - exit 1 -fi - -if [ -z "${SSID:-}" ] || [ -z "${PASSWORD:-}" ] || [ -z "${INTERFACE:-}" ]; then - log "ERR" "Config file is missing required values (SSID, PASSWORD, INTERFACE)." - exit 1 -fi -# --- v0.3 Backward Compatibility Check --- -if [ -z "${SUPPORTED_CHANNELS:-}" ]; then - log "WARN" "SUPPORTED_CHANNELS not found in config. Was install.sh v0.3 run? Using safe defaults." - SUPPORTED_CHANNELS="1,2,3,4,5,6,7,8,9,10,11,36,40,44,48" -fi + # --- v0.3 Backward Compatibility Check --- + if [ -z "${SUPPORTED_CHANNELS:-}" ]; then + log "WARN" "SUPPORTED_CHANNELS not found in config. Was install.sh v0.3 run? Using safe defaults." + SUPPORTED_CHANNELS="1,2,3,4,5,6,7,8,9,10,11,36,40,44,48" + fi +} ac_online() { if [ -n "${POWER_SUPPLY:-}" ] && @@ -135,57 +154,291 @@ ac_online() { return 1 } +# --- create_ap Instance Tracking --- +# Fiero only ever touches the create_ap process it started (recorded in +# $PID_FILE). Other hotspots on the system - the linux-wifi-hotspot GUI, +# another create_ap - are left alone. CREATE_AP_PID="" -cleanup() { - if [ "${SKIP_CLEANUP:-0}" = "1" ]; then +ensure_run_dir() { + install -d -m 0700 "$RUN_DIR" +} + +read_state() { + [ -f "$1" ] && head -n1 "$1" 2>/dev/null +} + +is_create_ap_pid() { + [[ "${1:-}" =~ ^[0-9]+$ ]] || return 1 + tr '\0' ' ' <"/proc/$1/cmdline" 2>/dev/null | grep -q 'create_ap' +} + +current_pid() { + local pid="${CREATE_AP_PID:-}" + [ -n "$pid" ] || pid=$(read_state "$PID_FILE") + is_create_ap_pid "$pid" && printf '%s' "$pid" +} + +# create_ap writes its own PID to /pid, and the AP interface it +# actually uses (ap0, or the physical card with --no-virt) to +# /wifi_iface. +find_confdir() { + local d + for d in /tmp/create_ap.*.conf.*; do + [ -f "$d/pid" ] || continue + if [ "$(cat "$d/pid" 2>/dev/null)" = "$1" ]; then + printf '%s' "$d" + return 0 + fi + done + return 1 +} + +is_confdir_path() { + [[ "${1:-}" =~ ^/tmp/create_ap\.[A-Za-z0-9_.-]+\.conf\.[A-Za-z0-9]+$ ]] +} + +# create_ap parses --config files with a plain `read` (no -r): backslashes +# are dropped and leading/trailing whitespace is trimmed. Only values that +# survive that unchanged can go through the file. +config_safe_value() { + case "$1" in *\\*) return 1 ;; esac + [ "$1" = "${1#[[:space:]]}" ] && [ "$1" = "${1%[[:space:]]}" ] +} + +# Write a root-only (0600) create_ap config so the passphrase never appears +# on a command line, where any local user could read it via ps or +# /proc//cmdline. +write_create_ap_config() { + local ch="$1" + config_safe_value "$SSID" && config_safe_value "$PASSWORD" || return 1 + ( + umask 077 + { + printf 'WIFI_IFACE=%s\n' "$INTERFACE" + printf 'INTERNET_IFACE=%s\n' "$INTERFACE" + printf 'SSID=%s\n' "$SSID" + printf 'PASSPHRASE=%s\n' "$PASSWORD" + printf 'CHANNEL=%s\n' "$ch" + } >"$AP_CONF" + ) +} + +launch_create_ap() { + local ch="$1" + ensure_run_dir + rm -f "$CONFDIR_FILE" "$IFACE_FILE" + if write_create_ap_config "$ch"; then + create_ap --config "$AP_CONF" 9>&- & + else + log "WARN" "SSID or password contains a backslash or leading/trailing spaces, which create_ap's config file cannot hold. Passing them on the command line instead (visible to local users via ps)." + create_ap "$INTERFACE" "$INTERFACE" "$SSID" "$PASSWORD" -c "$ch" 9>&- & + fi + CREATE_AP_PID=$! + printf '%s\n' "$CREATE_AP_PID" >"$PID_FILE" +} + +# Wait until our create_ap has a running hostapd and an AP interface. +wait_for_ap() { + local attempt confdir ap_iface + sleep 0.5 + for ((attempt = 1; attempt <= AP_START_TIMEOUT; attempt++)); do + kill -0 "$CREATE_AP_PID" 2>/dev/null || return 1 + confdir=$(find_confdir "$CREATE_AP_PID") || confdir="" + if [ -n "$confdir" ] && [ -f "$confdir/wifi_iface" ]; then + ap_iface=$(cat "$confdir/wifi_iface" 2>/dev/null) + if [ -n "$ap_iface" ] && iw dev "$ap_iface" info >/dev/null 2>&1 && + pgrep -f "hostapd.*$(escape_regex "$confdir")/hostapd.conf" >/dev/null; then + printf '%s\n' "$confdir" >"$CONFDIR_FILE" + printf '%s\n' "$ap_iface" >"$IFACE_FILE" + # create_ap has read its config; don't keep the passphrase around + rm -f "$AP_CONF" + return 0 + fi + fi + sleep 1 + done + return 1 +} + +# create_ap saves the original forwarding values before enabling NAT and puts +# them back in its own cleanup. If we had to kill it, do that for it - unless +# another create_ap instance still needs forwarding. +restore_ip_forward() { + local common="/tmp/create_ap.common.conf" + if pgrep -f '(^|/)create_ap( |$)' >/dev/null; then return 0 fi - create_ap --stop "$INTERFACE" 2>/dev/null || true - if [ -n "$CREATE_AP_PID" ]; then - kill "$CREATE_AP_PID" 2>/dev/null || true + if [ -f "$common/ip_forward" ]; then + cat "$common/ip_forward" >/proc/sys/net/ipv4/ip_forward 2>/dev/null || true + log "WARN" "Restored net.ipv4.ip_forward to $(cat "$common/ip_forward" 2>/dev/null)." + fi + if [ -f "$common/${INTERFACE}_forwarding" ]; then + cat "$common/${INTERFACE}_forwarding" >"/proc/sys/net/ipv4/conf/$INTERFACE/forwarding" 2>/dev/null || true fi - local escaped_if - escaped_if=$(escape_regex "$INTERFACE") - pkill -f "create_ap.*$escaped_if" 2>/dev/null || true - for dev in $(iw dev 2>/dev/null | awk '$1=="Interface" && $2 ~ /^ap[0-9]+/ {print $2}'); do - ip link set dev "$dev" down 2>/dev/null || true - iw dev "$dev" del 2>/dev/null || true - ip link delete "$dev" 2>/dev/null || true - done - # Note: Deliberately skipping p2p-dev-$INTERFACE deletion to prevent iwlwifi firmware crashes - find /tmp -maxdepth 1 -name "create_ap*" -uid 0 ! -type l -exec rm -rf {} + 2>/dev/null || true } -# trap fires on normal exit and on SIGINT/SIGTERM; cleanup is idempotent -trap cleanup EXIT -trap 'exit 130' INT -trap 'exit 0' TERM + +# Ask our create_ap to stop and wait for it. create_ap's own cleanup restores +# ip_forward, iptables and NetworkManager, so it must be allowed to finish +# before anything else is removed. +stop_create_ap() { + local pid ap_iface confdir forced=0 i + pid=$(current_pid) || pid="" + ap_iface=$(read_state "$IFACE_FILE") + confdir=$(read_state "$CONFDIR_FILE") + + if [ -n "$pid" ]; then + # USR1 is create_ap's clean-exit signal (same as `create_ap --stop`) + kill -USR1 "$pid" 2>/dev/null || true + for ((i = 0; i < AP_STOP_TIMEOUT * 2; i++)); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + log "WARN" "create_ap (pid $pid) did not exit within ${AP_STOP_TIMEOUT}s; forcing it." + kill -TERM "$pid" 2>/dev/null || true + sleep 2 + kill -KILL "$pid" 2>/dev/null || true + forced=1 + fi + fi + + if [ "$forced" -eq 1 ] && is_confdir_path "$confdir"; then + # hostapd and dnsmasq of *this* instance carry the confdir in their + # command line + pkill -f "$(escape_regex "$confdir")/" 2>/dev/null || true + restore_ip_forward + rm -rf -- "$confdir" + fi + + # Only a leftover virtual interface is removed, never the physical card. + # Deliberately never touches p2p-dev-* to prevent iwlwifi firmware crashes. + if [ -n "$ap_iface" ] && [ "$ap_iface" != "${INTERFACE:-}" ] && + [[ "$ap_iface" =~ ^ap[0-9]+$ ]] && iw dev "$ap_iface" info >/dev/null 2>&1; then + log "WARN" "Removing leftover AP interface $ap_iface." + ip link set dev "$ap_iface" down 2>/dev/null || true + iw dev "$ap_iface" del 2>/dev/null || true + fi + + CREATE_AP_PID="" + rm -f "$PID_FILE" "$CONFDIR_FILE" "$IFACE_FILE" "$AP_CONF" +} + +cleanup() { + stop_create_ap + rm -f "$STOP_FLAG" +} detect_channel() { get_channel "$INTERFACE" && CHANNEL="$CH" } +# Watch the upstream link while the AP runs: follow channel changes, stop when +# upstream is gone for good, stop when create_ap dies. +monitor_hotspot() { + local last_channel="$CHANNEL" down_since="" poll_int=2 + local now link_out link_rc current_ch + + while true; do + if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then + if [ -f "$STOP_FLAG" ]; then + log "INFO" "create_ap stopped on request." + else + log "ERR" "create_ap process exited unexpectedly. Shutting down hotspot." + notify "Hotspot stopped: create_ap process exited" + fi + break + fi + + now=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1) + link_out=$(iw dev "$INTERFACE" link 2>/dev/null) + link_rc=$? + + if [ "$link_rc" -eq 0 ] && printf '%s' "$link_out" | grep -q "Connected to"; then + down_since="" + poll_int=2 + + current_ch="" + if get_channel "$INTERFACE"; then current_ch="$CH"; fi + if [ -n "$current_ch" ] && [ "$current_ch" != "$last_channel" ]; then + log "WARN" "Upstream channel changed ($last_channel -> $current_ch). Re-evaluating AP..." + if [[ ",$SUPPORTED_CHANNELS," != *",$current_ch,"* ]]; then + log "ERR" "New channel $current_ch is unsupported for AP broadcast. Shutting down." + notify "Hotspot stopped: channel $current_ch unsupported" + break + fi + + notify "Hotspot restarting: channel changed to $current_ch" + stop_create_ap + CHANNEL="$current_ch" + last_channel="$current_ch" + + launch_create_ap "$CHANNEL" + if ! wait_for_ap; then + log "ERR" "Failed to bring up AP on channel $CHANNEL within ${AP_START_TIMEOUT}s." + notify "Hotspot stopped: AP re-init failed" + break + fi + log "INFO" "create_ap successfully restarted on channel $CHANNEL (pid $CREATE_AP_PID)." + notify "Hotspot is live on channel $CHANNEL!" + fi + else + # "Not connected", or iw itself failed (driver reset, card removed) + poll_int=1 + if [ -z "$down_since" ]; then + down_since="$now" + fi + if [ $((now - down_since)) -ge "$UPSTREAM_GRACE" ]; then + sleep 2 + if ! iw dev "$INTERFACE" link 2>/dev/null | grep -q "Connected to"; then + log "ERR" "Upstream Wi-Fi disconnected permanently. Shutting down hotspot." + notify "Hotspot stopped: upstream Wi-Fi disconnected" + break + fi + down_since="" + fi + fi + + sleep "$poll_int" + done +} + start_hotspot() { if [ "$EUID" -ne 0 ]; then - SKIP_CLEANUP=1 log "ERR" "Starting the hotspot requires root. Run: sudo fiero-hotspot start" exit 1 fi - exec 9>/run/fiero-hotspot.lock + load_config + + exec 9>"$LOCK_FILE" flock -n 9 || { log "INFO" "Another instance is running. Exiting." - SKIP_CLEANUP=1 exit 0 } + # Leftover from an earlier run of ours (e.g. a killed foreground session) + if [ -n "$(current_pid)" ]; then + log "WARN" "Stopping stale create_ap from a previous run..." + stop_create_ap + fi + local escaped_if escaped_if=$(escape_regex "$INTERFACE") - if pgrep -f "create_ap.*$escaped_if" >/dev/null && iw dev | grep -qE '^\s*Interface ap[0-9]'; then - log "INFO" "Hotspot already running. Skipping." - SKIP_CLEANUP=1 + if pgrep -f "create_ap.*$escaped_if" >/dev/null; then + log "WARN" "create_ap is already running on $INTERFACE (started outside Fiero?). Not touching it." + log "WARN" "Stop it first, e.g.: sudo create_ap --stop $INTERFACE" + notify "Hotspot not started: another hotspot is already running on $INTERFACE" exit 0 fi + ensure_run_dir + rm -f "$STOP_FLAG" + + # trap fires on normal exit and on SIGINT/SIGTERM; cleanup is idempotent + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 0' TERM + log "INFO" "Starting hotspot process..." refresh_supported_channels @@ -202,158 +455,74 @@ start_hotspot() { exit 0 fi - log "INFO" "Cleaning up stale create_ap and virtual interfaces..." - cleanup - notify "Starting hotspot on channel $CHANNEL..." log "INFO" "Launching create_ap in background..." - create_ap "$INTERFACE" "$INTERFACE" "$SSID" "$PASSWORD" -c "$CHANNEL" 9>&- & - CREATE_AP_PID=$! + launch_create_ap "$CHANNEL" - sleep 0.5 - if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then - log "ERR" "create_ap process exited immediately after launch." - cleanup + if ! wait_for_ap; then + if kill -0 "$CREATE_AP_PID" 2>/dev/null; then + log "ERR" "create_ap failed to start (pid $CREATE_AP_PID, no AP within ${AP_START_TIMEOUT}s)." + else + log "ERR" "create_ap exited during startup." + fi + notify "Hotspot could not start (see: journalctl -u fiero-hotspot)" exit 1 fi - for ((attempt = 1; attempt <= 8; attempt++)); do - if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then - break - fi - if iw dev 2>/dev/null | grep -qE '^\s*Interface ap[0-9]+' && pgrep -f "hostapd.*/tmp/create_ap" >/dev/null; then - log "INFO" "create_ap is live (pid $CREATE_AP_PID, AP interface up)." - notify "Hotspot is live! SSID: $SSID" - local last_channel="$CHANNEL" - local down_since="" - local poll_int=2 - rm -f "$SHUTDOWN_LOCK" - while true; do - if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then - if [ -f "$SHUTDOWN_LOCK" ]; then - rm -f "$SHUTDOWN_LOCK" - log "WARN" "create_ap exited during manual stop; treating as clean stop." - break - fi - log "ERR" "create_ap process exited unexpectedly. Shutting down hotspot." - notify "Hotspot stopped: create_ap process exited" - break - fi - - local up_uptime link_out link_rc - up_uptime=$(cut -d' ' -f1 /proc/uptime | cut -d. -f1) - link_out=$(iw dev "$INTERFACE" link 2>/dev/null) - link_rc=$? - - if [ "$link_rc" -eq 0 ] && printf '%s' "$link_out" | grep -q "Connected to"; then - down_since="" - poll_int=2 - - local current_ch="" - if get_channel "$INTERFACE"; then current_ch="$CH"; fi - if [ -n "$current_ch" ] && [ "$current_ch" != "$last_channel" ]; then - log "WARN" "Upstream channel changed ($last_channel -> $current_ch). Re-evaluating AP..." - if [[ ",$SUPPORTED_CHANNELS," != *",$current_ch,"* ]]; then - log "ERR" "New channel $current_ch is unsupported for AP broadcast. Shutting down." - notify "Hotspot stopped: channel $current_ch unsupported" - break - fi - - notify "Hotspot restarting: channel changed to $current_ch" - cleanup - CHANNEL="$current_ch" - last_channel="$current_ch" - - create_ap "$INTERFACE" "$INTERFACE" "$SSID" "$PASSWORD" -c "$CHANNEL" 9>&- & - CREATE_AP_PID=$! - - sleep 0.5 - if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then - log "ERR" "create_ap failed to restart on channel $CHANNEL." - notify "Hotspot stopped: create_ap restart failed" - break - fi - - local reinit_ok=0 - for ((reinit_attempt = 1; reinit_attempt <= 8; reinit_attempt++)); do - if ! kill -0 "$CREATE_AP_PID" 2>/dev/null; then - break - fi - if iw dev 2>/dev/null | grep -qE '^\s*Interface ap[0-9]+' && pgrep -f "hostapd.*/tmp/create_ap" >/dev/null; then - reinit_ok=1 - log "INFO" "create_ap successfully restarted on channel $CHANNEL (pid $CREATE_AP_PID)." - notify "Hotspot is live on channel $CHANNEL!" - break - fi - sleep 1 - done - - if [ "$reinit_ok" -ne 1 ]; then - log "ERR" "Failed to bring up AP on channel $CHANNEL within 8s." - notify "Hotspot stopped: AP re-init failed" - break - fi - fi - elif [ "$link_rc" -eq 0 ] && printf '%s' "$link_out" | grep -q "Not connected"; then - poll_int=1 - if [ -z "$down_since" ]; then - down_since="$up_uptime" - fi - if [ $((up_uptime - down_since)) -ge "$UPSTREAM_GRACE" ]; then - sleep 2 - link_out=$(iw dev "$INTERFACE" link 2>/dev/null) - if ! printf '%s' "$link_out" | grep -q "Connected to"; then - log "ERR" "Upstream Wi-Fi disconnected permanently. Shutting down hotspot." - notify "Hotspot stopped: upstream Wi-Fi disconnected" - break - fi - down_since="" - fi - fi + log "INFO" "create_ap is live (pid $CREATE_AP_PID, AP interface $(read_state "$IFACE_FILE"))." + notify "Hotspot is live! SSID: $SSID" - sleep "$poll_int" - done - cleanup - return 0 - fi - sleep 1 - done - - log "ERR" "create_ap failed to start (pid $CREATE_AP_PID, no AP interface within 8s)." - cleanup - exit 1 + monitor_hotspot + exit 0 } stop_hotspot() { if [ "$EUID" -ne 0 ]; then - SKIP_CLEANUP=1 log "ERR" "Stopping the hotspot requires root. Run: sudo fiero-hotspot stop" exit 1 fi - touch "$SHUTDOWN_LOCK" + load_config + + # Run by hand while systemd owns the hotspot: let systemd stop it, so + # the service state stays correct. (INVOCATION_ID is set for processes + # started by systemd, including ExecStop=.) + if [ -z "${INVOCATION_ID:-}" ] && systemctl is-active --quiet fiero-hotspot.service 2>/dev/null; then + log "INFO" "Stopping fiero-hotspot.service..." + exec systemctl stop fiero-hotspot.service + fi + + ensure_run_dir + touch "$STOP_FLAG" log "INFO" "Stopping hotspot..." + stop_create_ap + if ac_online; then notify "Hotspot stopped manually" else notify "Hotspot stopped (charger unplugged)" fi - # Teardown is handled automatically by the EXIT trap } status_hotspot() { - SKIP_CLEANUP=1 if [ "$EUID" -ne 0 ]; then log "ERR" "Status requires root. Run: sudo fiero-hotspot status" exit 1 fi + load_config - local svc_state ap_iface ap_channel ap_freq client_count ac_status + local svc_state pid ap_iface ap_channel ap_freq client_count ac_status svc_state=$(systemctl is-active fiero-hotspot.service 2>/dev/null || true) svc_state="${svc_state:-inactive}" - ap_iface=$(iw dev 2>/dev/null | awk '$1=="Interface" && $2 ~ /^ap[0-9]+/ {print $2; exit}') + pid=$(current_pid) || pid="" + if [ "$svc_state" != "active" ] && [ -n "$pid" ]; then + svc_state="running in foreground (pid $pid)" + fi + + ap_iface="" + [ -n "$pid" ] && ap_iface=$(read_state "$IFACE_FILE") ap_iface="${ap_iface:-none}" if [ "$ap_iface" != "none" ]; then @@ -401,23 +570,31 @@ status_hotspot() { } clients_hotspot() { - SKIP_CLEANUP=1 if [ "$EUID" -ne 0 ]; then log "ERR" "Client listing requires root. Run: sudo fiero-hotspot clients" exit 1 fi + load_config - local ap_iface - ap_iface=$(iw dev 2>/dev/null | awk '$1=="Interface" && $2 ~ /^ap[0-9]+/ {print $2; exit}') + local pid ap_iface confdir + pid=$(current_pid) || pid="" + ap_iface="" + [ -n "$pid" ] && ap_iface=$(read_state "$IFACE_FILE") if [ -z "$ap_iface" ]; then log "INFO" "Hotspot is not running. No AP interface found." exit 0 fi + # The service runs with a private /tmp; /proc//root reaches the + # create_ap process's own view of it from outside the sandbox. local -A mac_to_ip mac_to_host local lease_file - for lease_file in /var/lib/misc/dnsmasq.leases /tmp/create_ap.*/dnsmasq.leases; do + confdir=$(read_state "$CONFDIR_FILE") + for lease_file in /var/lib/misc/dnsmasq.leases "/proc/$pid/root$confdir/dnsmasq.leases"; do + if [ "$lease_file" != "/var/lib/misc/dnsmasq.leases" ] && ! is_confdir_path "$confdir"; then + continue + fi [ -f "$lease_file" ] || continue while IFS=' ' read -r _expiry mac ip hostname _rest; do mac_to_ip["$mac"]="$ip" @@ -439,11 +616,15 @@ clients_hotspot() { printf " %-17s %-10s %-15s %s\n" "MAC" "Signal" "IP" "Hostname" printf " %-17s %-10s %-15s %s\n" "─────────────────" "──────────" "───────────────" "─────────────" - local current_mac="" + # iw prints "\tsignal: \t-45 [-47, -46] dBm" (two spaces, a tab, and an + # optional per-chain list), so match any whitespace and stop at the number. + local current_mac="" line + local station_re='^Station ([0-9a-fA-F:]+)' + local signal_re='^[[:space:]]*signal:[[:space:]]+(-?[0-9]+)' while IFS= read -r line; do - if [[ "$line" =~ ^Station\ ([0-9a-fA-F:]+) ]]; then + if [[ "$line" =~ $station_re ]]; then current_mac="${BASH_REMATCH[1]}" - elif [[ "$line" =~ signal:\ (-?[0-9]+)\ dBm ]]; then + elif [[ "$line" =~ $signal_re ]]; then local signal="${BASH_REMATCH[1]}" local ip="${mac_to_ip[$current_mac]:--}" local host="${mac_to_host[$current_mac]:--}" @@ -452,30 +633,26 @@ clients_hotspot() { done <<<"$station_output" } -case "${1:-}" in -start) start_hotspot ;; -stop) stop_hotspot ;; -status) status_hotspot ;; -clients) clients_hotspot ;; -mode | toggle) - SKIP_CLEANUP=1 +update_auto_prompt() { + local val="$1" + if grep -q '^AUTO_PROMPT=' "$CONFIG_FILE" 2>/dev/null; then + sed -i "s/^AUTO_PROMPT=.*/AUTO_PROMPT='${val}'/" "$CONFIG_FILE" + else + echo "AUTO_PROMPT='${val}'" >>"$CONFIG_FILE" + fi + chown root:"$TARGET_USER" "$CONFIG_FILE" 2>/dev/null || true + chmod 640 "$CONFIG_FILE" 2>/dev/null || true +} + +mode_hotspot() { if [ "$EUID" -ne 0 ]; then log "ERR" "Mode toggle requires root. Run: sudo fiero-hotspot mode" exit 1 fi - cfg="/etc/fiero-hotspot.conf" - current="${AUTO_PROMPT:-true}" - update_auto_prompt() { - local val="$1" - if grep -q '^AUTO_PROMPT=' "$cfg" 2>/dev/null; then - sed -i "s/^AUTO_PROMPT=.*/AUTO_PROMPT='${val}'/" "$cfg" - else - echo "AUTO_PROMPT='${val}'" >>"$cfg" - fi - chown root:"$TARGET_USER" "$cfg" 2>/dev/null || true - chmod 640 "$cfg" 2>/dev/null || true - } - case "${2:-}" in + load_config + + local current="${AUTO_PROMPT:-true}" ans + case "${1:-}" in auto | enable | on) update_auto_prompt 'true' log "INFO" "Auto-prompt enabled." @@ -508,17 +685,13 @@ mode | toggle) esac ;; *) - log "ERR" "Unknown mode: '$2'. Use 'auto' or 'manual'." + log "ERR" "Unknown mode: '$1'. Use 'auto' or 'manual'." exit 1 ;; esac - ;; -version | -v | --version) - SKIP_CLEANUP=1 - printf "fiero-hotspot v%s\n" "$VERSION" - ;; -help | -h | --help | "") - SKIP_CLEANUP=1 +} + +usage() { printf "Usage: fiero-hotspot {start|stop|status|clients|mode|version|help}\n" printf "\n" printf " start Start the hotspot daemon\n" @@ -528,9 +701,21 @@ help | -h | --help | "") printf " mode Toggle or set trigger mode (auto|manual)\n" printf " version Show version information (also: -v, --version)\n" printf " help Show this help message\n" +} + +case "${1:-}" in +start) start_hotspot ;; +stop) stop_hotspot ;; +status) status_hotspot ;; +clients) clients_hotspot ;; +mode | toggle) mode_hotspot "${2:-}" ;; +version | -v | --version) + printf "fiero-hotspot v%s\n" "$VERSION" + ;; +help | -h | --help | "") + usage ;; *) - SKIP_CLEANUP=1 log "ERR" "Unknown action: '$1'. Run 'fiero-hotspot help' for usage." exit 1 ;; diff --git a/fiero-prompt.sh b/fiero-prompt.sh index 8c9fed7..d7c8cbd 100755 --- a/fiero-prompt.sh +++ b/fiero-prompt.sh @@ -19,18 +19,17 @@ set -u PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH -# Prevent udev bounce spam with non-blocking lock +# Serialises the debounce/supersede step below. Held only for that step, not +# while the notification is on screen, so a charger flip during an open +# prompt can still reach the code that cancels the stale prompt. USER_LOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/fiero-prompt.lock" -exec 9>"$USER_LOCK" -if ! flock -n 9; then - exit 0 -fi CONFIG_FILE="/etc/fiero-hotspot.conf" if [ -r "$CONFIG_FILE" ]; then conf_owner=$(stat -c '%U' "$CONFIG_FILE" 2>/dev/null) conf_perms=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null) if [ "$conf_owner" = "root" ] && { [ "$conf_perms" = "640" ] || [ "$conf_perms" = "600" ]; }; then + # shellcheck disable=SC1090 source "$CONFIG_FILE" fi fi @@ -55,7 +54,7 @@ freq_to_channel() { } get_channel() { - local line freq + local line line=$(iw dev "$1" info 2>/dev/null | grep -m1 'channel ') [ -n "$line" ] || return 1 FREQ=$(printf '%s\n' "$line" | awk '{for(i=1;i/dev/null || true +exec 9>"$USER_LOCK" +if ! flock -w 5 9; then + exit 0 +fi now=$(date +%s) if [ -f "$STATE_FILE" ]; then @@ -142,6 +137,24 @@ if [ -f "$STATE_FILE" ]; then fi echo "$now:$current_state:$$" >"$STATE_FILE" +flock -u 9 +exec 9>&- + +# A newer charger event may have replaced this prompt while it was open. +still_current() { + [ "$(cut -d: -f3 "$STATE_FILE" 2>/dev/null)" = "$$" ] +} + +# --- Service state awareness: don't prompt when there is nothing to do. +# This runs after the state update above, so that e.g. unplugging while a +# "Start?" prompt is open still cancels that prompt even though nothing needs +# stopping. +if [ "$current_state" = "online" ] && systemctl is-active --quiet fiero-hotspot.service; then + exit 0 +fi +if [ "$current_state" = "offline" ] && ! systemctl is-active --quiet fiero-hotspot.service; then + exit 0 +fi if [ "$current_state" = "online" ]; then refresh_supported_channels @@ -172,12 +185,17 @@ if [ "$current_state" = "online" ]; then --expire-time=10000 \ --action="start=Start" \ --action="ignore=Ignore") - # Explicit ignore -> exit. Timeout -> fallback only if still on AC. + still_current || exit 0 + # Explicit ignore -> exit. Timeout or dismissed -> start only if the user + # opted in (AUTO_START_ON_TIMEOUT='true') and we are still on AC. + # Broadcasting a network should need a clear "yes". if [ "$result" = "ignore" ]; then exit 0 fi - if [ -z "$result" ] && ! ac_online; then - exit 0 + if [ -z "$result" ]; then + if [ "${AUTO_START_ON_TIMEOUT:-false}" != "true" ] || ! ac_online; then + exit 0 + fi fi sudo -n /usr/bin/systemctl start fiero-hotspot.service else @@ -186,7 +204,9 @@ else --expire-time=10000 \ --action="stop=Stop" \ --action="keep=Keep Running") - # Explicit keep -> exit. Timeout -> fallback only if still unplugged. + still_current || exit 0 + # Explicit keep -> exit. Timeout -> stop (the safe choice on battery), + # but only if still unplugged. if [ "$result" = "keep" ]; then exit 0 fi diff --git a/install.sh b/install.sh index 3f2bd2c..1c079de 100755 --- a/install.sh +++ b/install.sh @@ -79,118 +79,206 @@ if [ ${#MISSING_CMDS[@]} -ne 0 ]; then exit 1 fi -# --------------------------------------------------------- -# 2. INTERFACE & HARDWARE DISCOVERY -# --------------------------------------------------------- -INTERFACE=$(iw dev | awk '$1=="Interface" && $2 !~ /^ap[0-9]+/ {print $2; exit}') -if [ -z "$INTERFACE" ]; then - echo "Could not detect a Wi-Fi interface." - exit 1 -fi -POWER_SUPPLY="" -for supply in /sys/class/power_supply/*; do - if [ -f "$supply/type" ] && grep -q "^Mains$" "$supply/type"; then - POWER_SUPPLY=$(basename "$supply") - break - fi -done +SRC_DIR=$(cd "$(dirname "$0")" && pwd) +CONFIG_PATH="/etc/fiero-hotspot.conf" +SUDOERS_FILE="/etc/sudoers.d/fiero-hotspot" +# sudo ignores sudoers.d files whose name contains a dot, so this temp file +# stays inert until it has been validated and renamed into place. +SUDOERS_TMP="/etc/sudoers.d/.fiero-hotspot.tmp" +CONFIG_TMP="${CONFIG_PATH}.tmp.$$" +trap 'rm -f "$SUDOERS_TMP" "$CONFIG_TMP"' EXIT + +# Read one value from an existing config without polluting this shell. +conf_get() { + ( + # shellcheck disable=SC1090 + . "$CONFIG_PATH" >/dev/null 2>&1 + printf '%s' "${!1:-}" + ) +} -if [ -z "$POWER_SUPPLY" ]; then - echo "Could not detect the AC power supply name." - exit 1 -fi +# create_ap reads its config file with a plain `read`, which drops +# backslashes and trims surrounding spaces (see fiero-hotspot.sh). +has_config_unsafe_chars() { + case "$1" in *\\* | [[:space:]]* | *[[:space:]]) return 0 ;; esac + return 1 +} -echo "Detected Wi-Fi interface: $INTERFACE" -echo "Detected power supply: $POWER_SUPPLY" +has_non_printable_ascii() { + printf '%s' "$1" | LC_ALL=C grep -q '[^ -~]' +} # --------------------------------------------------------- -# 3. RF SPECTRUM DISCOVERY +# 2. TARGET USER & EXISTING CONFIGURATION # --------------------------------------------------------- -echo "Discovering supported RF channels for $INTERFACE..." -PHY=$(iw dev "$INTERFACE" info | awk '/wiphy/{print "phy"$2}') +TARGET_USER="${SUDO_USER:-}" +if [ -z "$TARGET_USER" ] || [ "$TARGET_USER" = "root" ]; then + echo "Error: run the installer with sudo from the desktop user who should get the hotspot prompts," + echo " e.g. 'sudo ./install.sh' - not from a root shell." + exit 1 +fi -if [ -n "$PHY" ]; then - # Grab all channel lines, allow for decimal outputs (.0 MHz), exclude restricted flags, extract the channel number, and join with commas - SUPPORTED_CHANNELS=$(iw phy "$PHY" info | grep -E '\* [0-9]+(\.[0-9]+)? MHz \[[0-9]+\]' | grep -vE '(disabled|no IR|radar detection)' | awk -F'[][]' '{print $2}' | paste -sd, -) +KEEP_CONFIG=0 +if [ -f "$CONFIG_PATH" ]; then + read -rp "Existing configuration found at $CONFIG_PATH. Keep it? [Y/n]: " KEEP_INPUT + case "${KEEP_INPUT:-Y}" in + [nN] | [nN][oO]) ;; + *) KEEP_CONFIG=1 ;; + esac fi -if [ -z "$SUPPORTED_CHANNELS" ]; then - SUPPORTED_CHANNELS="1,2,3,4,5,6,7,8,9,10,11,36,40,44,48" - echo "Warning: Could not parse PHY dynamically. Defaulting to safe fallback channels." -else - echo "Safe broadcast channels detected: $SUPPORTED_CHANNELS" +if [ "$KEEP_CONFIG" -eq 1 ]; then + existing_user=$(conf_get TARGET_USER) + if [ -n "$existing_user" ] && id -u "$existing_user" >/dev/null 2>&1; then + TARGET_USER="$existing_user" + fi + AUTO_PROMPT=$(conf_get AUTO_PROMPT) + AUTO_PROMPT="${AUTO_PROMPT:-true}" + echo "Keeping existing configuration (target user: $TARGET_USER)." fi +TARGET_UID=$(id -u "$TARGET_USER") -# --------------------------------------------------------- -# 4. PASSPHRASE VALIDATION -# --------------------------------------------------------- -echo "" -read -rp "Enter SSID for the hotspot: " SSID +if [ "$KEEP_CONFIG" -eq 0 ]; then + # --------------------------------------------------------- + # 3. INTERFACE & HARDWARE DISCOVERY + # --------------------------------------------------------- + mapfile -t WIFI_IFACES < <(iw dev | awk '$1=="Interface" && $2 !~ /^ap[0-9]+/ {print $2}') + if [ "${#WIFI_IFACES[@]}" -eq 0 ]; then + echo "Could not detect a Wi-Fi interface." + exit 1 + elif [ "${#WIFI_IFACES[@]}" -eq 1 ]; then + INTERFACE="${WIFI_IFACES[0]}" + else + echo "Multiple Wi-Fi interfaces found:" + for i in "${!WIFI_IFACES[@]}"; do + echo " $((i + 1))) ${WIFI_IFACES[$i]}" + done + while true; do + read -rp "Which one is connected to the upstream Wi-Fi? [1]: " IFACE_CHOICE + IFACE_CHOICE="${IFACE_CHOICE:-1}" + if [[ "$IFACE_CHOICE" =~ ^[0-9]+$ ]] && [ "$IFACE_CHOICE" -ge 1 ] && [ "$IFACE_CHOICE" -le "${#WIFI_IFACES[@]}" ]; then + INTERFACE="${WIFI_IFACES[$((IFACE_CHOICE - 1))]}" + break + fi + echo "Please enter a number between 1 and ${#WIFI_IFACES[@]}." + done + fi -if [ -z "$SSID" ]; then - echo "Error: SSID cannot be empty." - exit 1 -fi -if [ "${#SSID}" -gt 32 ]; then - echo "Error: SSID must be 32 characters or fewer (802.11 limit)." - exit 1 -fi -if printf '%s' "$SSID" | grep -qP '[^\x20-\x7E]'; then - echo "Error: SSID contains non-printable characters." - exit 1 -fi + POWER_SUPPLY="" + for supply in /sys/class/power_supply/*; do + if [ -f "$supply/type" ] && grep -q "^Mains$" "$supply/type"; then + POWER_SUPPLY=$(basename "$supply") + break + fi + done -while true; do - read -rsp "Enter password for the hotspot (min 8 chars): " PASSWORD - echo - read -rsp "Confirm password: " PASSWORD_CONFIRM - echo + if [ -z "$POWER_SUPPLY" ]; then + echo "Could not detect the AC power supply name." + exit 1 + fi - if [ "$PASSWORD" != "$PASSWORD_CONFIRM" ]; then - echo "Error: Passwords do not match. Please try again." - continue + echo "Detected Wi-Fi interface: $INTERFACE" + echo "Detected power supply: $POWER_SUPPLY" + + # --------------------------------------------------------- + # 4. RF SPECTRUM DISCOVERY + # --------------------------------------------------------- + echo "Discovering supported RF channels for $INTERFACE..." + PHY=$(iw dev "$INTERFACE" info | awk '/wiphy/{print "phy"$2}') + SUPPORTED_CHANNELS="" + + if [ -n "$PHY" ]; then + # Grab all channel lines, allow for decimal outputs (.0 MHz), exclude restricted flags, extract the channel number, and join with commas + SUPPORTED_CHANNELS=$(iw phy "$PHY" info | grep -E '\* [0-9]+(\.[0-9]+)? MHz \[[0-9]+\]' | grep -vE '(disabled|no IR|radar detection)' | awk -F'[][]' '{print $2}' | paste -sd, -) fi - if [ "${#PASSWORD}" -lt 8 ]; then - echo "Error: Password must be at least 8 characters long (WPA2 requirement). Please try again." - continue + if [ -z "$SUPPORTED_CHANNELS" ]; then + SUPPORTED_CHANNELS="1,2,3,4,5,6,7,8,9,10,11,36,40,44,48" + echo "Warning: Could not parse PHY dynamically. Defaulting to safe fallback channels." + else + echo "Safe broadcast channels detected: $SUPPORTED_CHANNELS" fi - if [ "${#PASSWORD}" -gt 63 ]; then - echo "Error: Password must be 63 characters or fewer (WPA2 limit). Please try again." - continue + # --------------------------------------------------------- + # 5. SSID & PASSPHRASE VALIDATION + # --------------------------------------------------------- + echo "" + read -rp "Enter SSID for the hotspot: " SSID + + if [ -z "$SSID" ]; then + echo "Error: SSID cannot be empty." + exit 1 + fi + if [ "${#SSID}" -gt 32 ]; then + echo "Error: SSID must be 32 characters or fewer (802.11 limit)." + exit 1 + fi + if has_non_printable_ascii "$SSID"; then + echo "Error: SSID contains non-printable characters." + exit 1 + fi + if has_config_unsafe_chars "$SSID"; then + echo "Error: SSID must not contain a backslash or start/end with a space." + exit 1 fi - break -done -echo + while true; do + read -rsp "Enter password for the hotspot (8-63 chars): " PASSWORD + echo + read -rsp "Confirm password: " PASSWORD_CONFIRM + echo -# --------------------------------------------------------- -# 4b. AUTO_PROMPT SELECTION -# --------------------------------------------------------- -read -rp "Enable automatic hotspot prompt on charger connection? [Y/n]: " AUTO_PROMPT_INPUT -case "${AUTO_PROMPT_INPUT:-Y}" in - [nN]|[nN][oO]) AUTO_PROMPT="false" ;; - *) AUTO_PROMPT="true" ;; -esac + if [ "$PASSWORD" != "$PASSWORD_CONFIRM" ]; then + echo "Error: Passwords do not match. Please try again." + continue + fi -# --------------------------------------------------------- -# 5. CONFIGURATION & DEPLOYMENT -# --------------------------------------------------------- -CONFIG_PATH="/etc/fiero-hotspot.conf" -TARGET_USER="${SUDO_USER:-$USER}" -TARGET_UID=$(id -u "$TARGET_USER") + if [ "${#PASSWORD}" -lt 8 ]; then + echo "Error: Password must be at least 8 characters long (WPA2 requirement). Please try again." + continue + fi -# Single-quote a value so the config can be sourced safely even if it -# contains quotes, $, backticks, or backslashes (e.g. an SSID like "Bob's 5G"). -shquote() { - local s="$1" - s="${s//\'/\'\\\'\'}" - printf "'%s'\n" "$s" -} + if [ "${#PASSWORD}" -gt 63 ]; then + echo "Error: Password must be 63 characters or fewer (WPA2 limit). Please try again." + continue + fi -cat > "${CONFIG_PATH}.tmp.$$" <"$CONFIG_TMP" < "$SUDOERS_FILE" <"$SUDOERS_TMP" +chmod 440 "$SUDOERS_TMP" +chown root:root "$SUDOERS_TMP" +if ! visudo -cf "$SUDOERS_TMP" >/dev/null; then + echo "Error: generated sudoers rule failed validation; not installing it." + exit 1 +fi +mv -f "$SUDOERS_TMP" "$SUDOERS_FILE" echo "Installed sudoers drop-in to $SUDOERS_FILE (permissions 440)." udevadm control --reload-rules @@ -237,7 +333,8 @@ echo echo "Installation complete." if [ "$AUTO_PROMPT" = "true" ]; then echo "When the charger is connected/disconnected, a desktop prompt will ask" - echo "whether to start/stop the hotspot (falls back after 10s)." + echo "whether to start/stop the hotspot. If you don't answer, nothing is started" + echo "(set AUTO_START_ON_TIMEOUT='true' in $CONFIG_PATH to change that)." TEST_CMD="sudo systemctl start fiero-hotspot.service" else echo "Manual mode: use 'sudo fiero-hotspot start' to start and 'sudo fiero-hotspot stop' to stop." diff --git a/test_harness.sh b/test_harness.sh index cf31427..83efaec 100755 --- a/test_harness.sh +++ b/test_harness.sh @@ -71,6 +71,7 @@ DBUS_MONITOR_PID="" EDGE_TMP_DIRS=() CONFIG_BACKUP="" +# shellcheck disable=SC2317,SC2329 # invoked via trap cleanup() { local exit_code=$? set +e @@ -796,6 +797,16 @@ fi # ------------------------------------------------------------------------------ SCRIPT_UNDER_TEST="/usr/local/bin/fiero-hotspot" +# Phase 9 exercises the *installed* copy. If it differs from the repository +# copy, every result below describes old code - say so loudly. +REPO_SCRIPT="$(cd "$(dirname "$0")" && pwd)/fiero-hotspot.sh" +if [[ -f "$REPO_SCRIPT" ]] && ! cmp -s "$REPO_SCRIPT" "$SCRIPT_UNDER_TEST"; then + echo "[WARN] $SCRIPT_UNDER_TEST differs from $REPO_SCRIPT - re-run install.sh to test your changes." + edge_result "Phase 9.0: installed fiero-hotspot matches repository copy" FAIL +else + edge_result "Phase 9.0: installed fiero-hotspot matches repository copy" PASS +fi + # 9a. Unprivileged execution & error routing set +e SU_START_OUT=$(su - "$TARGET_USER" -c "$SCRIPT_UNDER_TEST start" 2>&1) @@ -1248,11 +1259,17 @@ if [[ -n "$LINGERING_IFACES" ]]; then HARD_FAIL=1 fi -# Check lifecycle timeouts +# Check lifecycle timeouts. A skipped lifecycle is not a pass: the hotspot was +# never started, so the run is reported INCOMPLETE (exit 2), not PASS. +INCOMPLETE=0 if [[ "$STARTUP_SKIPPED" != "true" ]]; then if [[ "$STARTUP_SUCCESS" != "true" ]] || [[ "$TEARDOWN_SUCCESS" != "true" ]]; then HARD_FAIL=1 fi +else + echo "[INCOMPLETE] Hotspot start/stop was not tested (upstream on unsupported channel ${CURRENT_CHANNEL:-none})." + echo " Connect to a network on a supported channel and re-run before calling this a pass." + INCOMPLETE=1 fi # Check dmesg for kernel panic or firmware crash traces @@ -1301,7 +1318,13 @@ fi echo "$RESOURCE_SNAPSHOT" echo "----------------------------------------------------------------------" fi - echo "Final Test Result : $([[ "$HARD_FAIL" -eq 0 ]] && echo "PASS (Exit 0)" || echo "HARD FAIL (Exit 1)")" + if [[ "$HARD_FAIL" -ne 0 ]]; then + echo "Final Test Result : HARD FAIL (Exit 1)" + elif [[ "$INCOMPLETE" -ne 0 ]]; then + echo "Final Test Result : INCOMPLETE - lifecycle not tested (Exit 2)" + else + echo "Final Test Result : PASS (Exit 0)" + fi echo "======================================================================" } >"$LOG_DIR/summary.txt" @@ -1310,6 +1333,9 @@ cat "$LOG_DIR/summary.txt" if [[ "$HARD_FAIL" -ne 0 ]]; then exit 1 fi +if [[ "$INCOMPLETE" -ne 0 ]]; then + exit 2 +fi exit 0 diff --git a/uninstall.sh b/uninstall.sh index b8e1e71..fdf2e1b 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -6,6 +6,16 @@ if [ "$EUID" -ne 0 ]; then exit 1 fi +KEEP_CONFIG=0 +case "${1:-}" in + --keep-config) KEEP_CONFIG=1 ;; + "") ;; + *) + echo "Usage: sudo ./uninstall.sh [--keep-config]" + exit 1 + ;; +esac + echo "=== Fiero Hotspot Uninstaller ===" if systemctl list-unit-files | grep -q "^fiero-hotspot.service"; then @@ -14,17 +24,27 @@ if systemctl list-unit-files | grep -q "^fiero-hotspot.service"; then echo "Stopped and disabled fiero-hotspot.service" fi +# A hotspot started by hand (sudo fiero-hotspot start) is not a systemd unit; +# stop it cleanly so create_ap can restore ip_forward and NetworkManager. +if [ -x /usr/local/bin/fiero-hotspot ]; then + /usr/local/bin/fiero-hotspot stop >/dev/null 2>&1 || true +fi + rm -f /usr/local/bin/fiero-hotspot echo "Removed /usr/local/bin/fiero-hotspot" rm -f /usr/local/bin/fiero-prompt echo "Removed /usr/local/bin/fiero-prompt" -rm -f /etc/sudoers.d/fiero-hotspot +rm -f /etc/sudoers.d/fiero-hotspot /etc/sudoers.d/.fiero-hotspot.tmp echo "Removed /etc/sudoers.d/fiero-hotspot" -rm -f /etc/fiero-hotspot.conf -echo "Removed /etc/fiero-hotspot.conf" +if [ "$KEEP_CONFIG" -eq 1 ]; then + echo "Kept /etc/fiero-hotspot.conf (--keep-config)" +else + rm -f /etc/fiero-hotspot.conf + echo "Removed /etc/fiero-hotspot.conf" +fi rm -f /etc/systemd/system/fiero-hotspot.service echo "Removed /etc/systemd/system/fiero-hotspot.service" @@ -32,9 +52,11 @@ echo "Removed /etc/systemd/system/fiero-hotspot.service" rm -f /etc/udev/rules.d/99-fiero-hotspot.rules echo "Removed /etc/udev/rules.d/99-fiero-hotspot.rules" +rm -rf /run/fiero-hotspot rm -f /run/fiero-hotspot.lock /run/fiero-shutting-down.lock rm -f /run/user/*/fiero-prompt.lock /run/user/*/fiero-prompt.state 2>/dev/null || true -find /tmp -maxdepth 1 -name "create_ap*" -uid 0 ! -type l -exec rm -rf {} + 2>/dev/null || true +# /tmp/create_ap.* is left alone: create_ap removes its own directories, the +# service's are in its private /tmp, and others may belong to another tool. echo "Cleaned runtime state and lock files" udevadm control --reload-rules