Architecture¶
This document describes how apsta is put together and why. Read it before a non-trivial change.
Layers¶
flowchart TD
CLI["<b>apsta_cli/cli.py</b><br/>argparse, errors → exit codes"]
GUI["<b>apsta_gui</b> (GTK 4 / libadwaita)<br/>a client of the CLI:<br/>--json reads, pkexec writes"]
CMD["<b>cmd/*</b> · present<br/>thin: print results"]
SVC["<b>services/</b> · use cases<br/>hotspot.py: start / stop / status, strategy selection<br/>watch.py: apsta run keeps the hotspot healthy<br/>guard.py: runs it behind apsta start"]
CFG["<b>config/</b><br/>model, store, validate<br/>state.py"]
NET["<b>net/</b><br/>strategies, transaction<br/>hostapd, dnsmasq, nm<br/>firewall, supervisor<br/>iface, subnet, channels, clients<br/>wpa (wpa_supplicant: socket or D-Bus)"]
HW["<b>hw/</b><br/>combinations, capability<br/>interfaces, usb"]
CORE["<b>core/</b><br/>shell (argv only), paths, fsutil (atomic)<br/>lock, output, log, errors"]
CLI --> CMD
GUI -. "runs apsta" .-> CMD
CMD --> SVC
SVC --> CFG & NET & HW
CFG & NET & HW --> CORE
Dependencies only point downwards. core imports nothing from apsta.
hw, net and config don't print user-facing guidance; they raise
ApstaError subclasses that carry hints, and cli.py renders them and maps
them to exit codes.
Key decisions¶
Hardware capability comes from interface combinations¶
hw/combinations.py parses iw phy <phy> info into Combination(groups,
total, channels) and asks one question: can one AP and one managed
interface exist at the same time? That means placing each requested
interface into a group with spare capacity, within total. #channels <= 1
means the AP must share the STA's channel. Earlier versions guessed from
whether AP and managed appeared in the same #{} group, which was wrong in
both directions. Real driver outputs live in tests/fixtures/iw/.
Strategies + transactions¶
net/strategies.py holds four implementations of one interface
(unavailable, start(ctx, tx), stop(state)): hostapd, nmcli
(virtual interface), p2p (a Wi-Fi Direct group owner through
wpa_supplicant, net/wpa.py: its control socket, or D-Bus where there is no
socket; see wifi-direct.md) and nmcli-single. services/hotspot.start tries them
in order. Each start registers an undo step with the Transaction after
every side effect, so a failure part-way rolls back to a clean system before
the next strategy is tried.
flowchart TD
S(["apsta start"]) --> P["Plan: capability, WiFi channel,<br/>allowed channels, subnet"]
P -- "WiFi's channel can't host, and no<br/>Wi-Fi Direct or --allow-disconnect" --> E(["HardwareError with hints"])
P --> N{"Next strategy:<br/>hostapd → nmcli → p2p → nmcli-single"}
N -- "unavailable" --> N
N --> T["start(ctx, tx): each side effect<br/>registers its undo step"]
T -- "all steps OK, hotspot live" --> W["Write /run/apsta/state.json"] --> D(["Running"])
T -- "a step fails" --> R["tx.rollback(): undo in reverse order"] --> N
N -- "none left" --> F(["Error listing what each strategy hit"])
nmcli-single drops the WiFi connection, so while connected it is only tried
with --allow-disconnect.
When the WiFi's channel can't host (DFS, no IR, 6 GHz), the plan picks a
channel of the hotspot's own and marks sta_channel_usable=False: hostapd
and nmcli step aside, leaving p2p (cards whose combinations allow a
P2P-GO beside managed with #channels >= 2) and nmcli-single. If
neither works, the original channel explanation is raised with their reasons
appended. A p2p hotspot records same_channel_required=False, so the
watcher doesn't chase the WiFi's channel.
Settings, decisions and notes¶
Whatever apsta decides on its own, the user can set instead, and whatever it can't follow, it explains:
- Method:
--methodfor one run, else the profile'smethodsetting, elseauto(hotspot.resolve_method). The watcher is launched without--methodunless one was given, so it reads the same setting. - Channel:
net/channels.plan()returns aChannelPlanwith the channel, the reason in words ("the least crowded nearby", "your channel setting") andnotesfor anything asked for but not granted (a channel on the wrong band, or one the card may not start a network on). hotspot.build_context()adds notes the planner can't know: the band setting differs from a channel the hotspot must share with the WiFi, or the WiFi's channel can't host at all. Methods with a channel of their own (p2p,nmcli-single) followbandandchannelinstead of sharing.hotspot.start()puts a note about the method first (chosen in the settings, or which methods were skipped and why), and a strategy may add its own (p2p: the radio's speed is shared).
The notes are stored in HotspotState.notes, printed by apsta start and
apsta status, exposed as hotspot.notes in status --json (so the GUI shows
them under Why it runs this way), and logged with the hotspot_started
event in /var/log/apsta.log. Each is one plain sentence for the user.
Runtime state lives in /run¶
state.py writes HotspotState to /run/apsta/state.json once the hotspot
is fully up, with everything stop needs: interface names, subnet, firewall
backend and its undo data, the previous ip_forward value, client limits,
and the notes explaining the choices made at start (below).
/run is a tmpfs, so a crash or reboot can't leave a stale "running" record.
On start, a recorded hotspot that is no longer alive is cleaned up first.
Configuration (/etc/apsta/config.json, world-readable) and secrets
(/etc/apsta/secrets.json, 0600) are separate and written atomically.
Processes are supervised¶
net/supervisor.py runs hostapd and dnsmasq as transient systemd units
(apsta-hostapd.service, apsta-dnsmasq.service, Restart=on-failure,
logs in the journal). On non-systemd systems it falls back to pidfiles, and
checks a pid against /proc/<pid>/cmdline before signalling it.
The service watches, not just starts¶
apsta.service runs apsta run --wait-sta 30. A one-off apsta start
(and so the GUI) launches the same watcher as the transient unit
apsta-watch.service (services/guard.py, systemd only), which adopts the
hotspot that is already up; apsta stop stops the watcher before the
hotspot. services/watch.py polls every 5 s and decide() (a pure function)
restarts the hotspot when:
- it went down (resume from suspend, driver reset, hostapd gave up);
- it shares the WiFi's channel and the WiFi moved to another channel;
- it shares the WiFi's channel and the WiFi has been gone for 20 s. The AP may be what stops NetworkManager reconnecting on another channel.
When the watcher is stopped (SIGTERM) it stops the hotspot, unless
/run/apsta/keep-hotspot exists. apsta disable writes that marker while it
stops the service (cmd/service.py: keeping_hotspot()), so turning off Start
automatically leaves a running hotspot up; on systemd it then launches
apsta-watch.service to keep watching it. The watcher removes the marker as
it reads it, and disable removes it after a synchronous stop, so it never
outlives that one stop.
flowchart TD
Poll(["every 5 s"]) --> St{"state.json present?"}
St -- "no (apsta stop)" --> Exit(["exit"])
St -- "yes" --> Alive{"hotspot alive?"}
Alive -- "no" --> Restart
Alive -- "yes" --> Same{"hotspot shares the<br/>WiFi's channel?"}
Same -- "no" --> Poll
Same -- "yes" --> Link{"WiFi connected?"}
Link -- "yes, same channel" --> Poll
Link -- "yes, other channel" --> Restart
Link -- "no, for 20 s" --> Restart
Link -- "no, < 20 s" --> Poll
Restart["stop, then start again<br/>retry 10 s → 20 s → … → 5 min"] --> Poll
A p2p hotspot never shares the WiFi's channel (same_channel_required is
false in its state), so only the first rule applies to it. If the WiFi moved
to a channel the card can't host on, the restart falls back to a p2p group
on cards that support it; on others every retry fails with the "no IR"
error until the network moves back. See 5ghz-wifi.md and
wifi-direct.md.
Firewall backends record their own undo data¶
net/firewall.py chooses firewalld (if running), then iptables, then
nftables. Each backend's apply returns data its revert uses, so teardown
only removes what apsta added. For example, masquerading is removed only from
zones where apsta enabled it.
No shell, no secrets in argv¶
core/shell.run takes argv lists only (no shell=True anywhere). Passwords
travel through files: hostapd.conf (0600), NetworkManager keyfiles in
/run/NetworkManager/system-connections (0600), and stdin
(--password-stdin), never command-line arguments that other users can read
in ps.
The CLI's JSON output is an interface¶
status, detect, config, clients and start accept --json. The GUI is
built on that output and scripts may be too, so treat its keys as a public
interface: add keys freely, but don't rename or remove them without a
changelog entry. The keys are documented in json-output.md.
The GUI¶
apsta_gui is a GTK 4 / libadwaita application, and a client of the CLI.
It never imports apsta_cli logic (only its version string). That gives one
implementation of every operation, one privilege boundary, and a GUI that
can't put the system into states the CLI can't explain.
app.py Adw.Application: actions (refresh, about, quit), shortcuts,
startup fixes (bundled icons, missing font DPI), --background,
AppTray (tray menu actions → window)
window.py main window: header, tabs, toasts, busy spinner, 5 s refresh loop,
run_async / run_privileged helpers used by every page,
close-to-tray, notifications instead of toasts while hidden
tray.py system tray icon: StatusNotifierItem + com.canonical.dbusmenu
over Gio D-Bus (no GTK)
pages/ one class per tab, each builds its widgets and exposes update(data)
hotspot.py status hero + start/stop, connection details, profile switcher
clients.py device rows with a menu (speed limit, disconnect, block), blocked list
settings.py network settings, profiles, start at boot and login, hardware report
share.py Share dialog (QR code + password)
compat.py newest libadwaita widget when available, fallback otherwise
backend.py runs `apsta … --json` and `pkexec apsta …` (no GTK, unit-tested)
helpers.py pure formatting / text logic (no GTK, unit-tested)
Data flow. Every 5 seconds (and after every action) the window runs
apsta status --json in a worker thread and hands the result to each page's
update(data) on the main loop. apsta detect --json runs once at startup.
Pages never block the main loop: subprocesses always run through
window.run_async(work, done), and widgets are touched only in done.
sequenceDiagram
participant W as window.py (main loop)
participant T as worker thread
participant C as apsta CLI
loop every 5 s and after each action
W->>T: run_async(status)
T->>C: apsta status --json
C-->>T: JSON
T-->>W: done(data)
W->>W: page.update(data) for each tab
end
W->>T: run_privileged(start)
T->>C: pkexec /usr/bin/apsta start (secrets via stdin)
C-->>T: exit code + message
T-->>W: toast, then refresh
Changes go through window.run_privileged(work). It shows the spinner,
runs pkexec /usr/bin/apsta <args> in a thread, shows the outcome as a toast
and refreshes. Only one privileged action runs at a time. Secrets (new
passwords, the password fetched for Share) travel over stdin and stdout,
never argv. The polkit action com.github.apsta.manage (auth_admin_keep)
names apsta in the prompt and remembers authentication for a few minutes.
System tray. libayatana-appindicator is built on GTK 3 and can't load
next to GTK 4, so tray.py implements the two D-Bus interfaces itself: it
owns org.kde.StatusNotifierItem-<pid>-1, exports the item at
/StatusNotifierItem and the menu at /MenuBar, and registers with
org.kde.StatusNotifierWatcher (or org.freedesktop.StatusNotifierWatcher)
whenever one appears on the session bus. The menu is described by
helpers.tray_state(data, busy) (pure, unit-tested) and refreshed with the
window's 5-second status, so the tray is just another view of the same data.
Menu clicks go through the same run_privileged path as the window's
buttons. With no tray on the bus nothing is registered, and closing the
window quits as before; if the tray goes away while the window is hidden,
the window comes back.
Rules the pages follow:
- Periodic refreshes must not overwrite a field the user is editing. Pages remember the last value they wrote and update a field only if it still holds that value.
- Device rows are kept per MAC across refreshes, so an open menu isn't destroyed by the 5-second update.
- Everything user-controlled (SSIDs, hostnames) passes through
compat.esc()before reaching a row title, subtitle, toast or status page, because those are Pango markup.
Supporting old and new libadwaita. Distros ship very different versions
(1.1 on Ubuntu 22.04, 1.5 on 24.04, the latest on Arch/Fedora). The GUI uses
the newest widgets available and falls back on older systems, entirely inside
compat.py:
| Need | ≥ this libadwaita | Fallback |
|---|---|---|
| window layout | ToolbarView (1.4) | Gtk.Box |
| tabs | header + bottom bar via Breakpoint (1.4) | compact header switcher |
| text fields | EntryRow / PasswordEntryRow (1.2) | ActionRow + Gtk.Entry |
| toggles | SwitchRow (1.4) | ActionRow + Gtk.Switch |
| action rows | ButtonRow (1.6) | ActionRow + Gtk.Button |
| busy indicator | Adw.Spinner (1.6) | Gtk.Spinner |
| dialogs | Adw.Dialog (1.5, sheet on narrow) | modal Adw.Window |
| about | AboutDialog (1.5) / AboutWindow (1.2) | Gtk.AboutDialog |
Outside compat.py only libadwaita 1.1 / GTK 4.6 API is allowed;
tests/unit/test_gui.py fails otherwise. compat.ensure_font_dpi() works
around libadwaita 1.5 collapsing page width when the session provides no
font DPI.
Because GTK 4 and libadwaita 1 keep their API stable within the major
version (deprecations warn, nothing is removed) and the app pins
Gtk 4.0/Adw 1 via gi.require_version, library updates don't break it.
CI runs the GUI on the newest releases (Arch, Fedora) to catch problems early.
Extending¶
| Task | Where |
|---|---|
| New hotspot method | subclass Strategy in net/strategies.py, add to STRATEGIES |
| New firewall | class with name/apply/revert in net/firewall.py, register in detect() |
| New init system | ENABLE/DISABLE in cmd/service.py + a file in apsta_cli/data/ |
| New USB chipset | USB_CHIPSET_DB in hw/usb.py |
| Card detected wrongly | add its iw phy output to tests/fixtures/iw/ with a test |
| New command | parser in cli.py, handler in cmd/, logic in services/ |
| GUI: new tab | class in apsta_gui/pages/ with widget + update(data, detect); register in window.py |
| GUI: widget newer than libadwaita 1.1 | add a helper with a fallback to apsta_gui/compat.py |
| GUI: new action | a backend.py method calling the CLI + window.run_privileged(...) |
Shell completion is generated from the argparse tree, so new commands and flags are completable without extra work.
Testing¶
tests/unit/: pure functions and modules withFakeShell(a scriptable stand-in forcore.shell.run) and isolated paths (tests/support.py).-
tests/integration/: the real CLI, in-process, against a simulated network stack (fakeworld.py). That is fakeiw,ip,nmcli,hostapd,dnsmasq,iptables, … executables that share state in a JSON file and a fake sysfs. These tests cover full start → clients → stop lifecycles, fallback with rollback, stale-state recovery, DFS refusal and more, with no root and no hardware. -
tests/unit/test_gui.py: the GUI's non-GTK parts (backend.py,helpers.py) and a portability check that GTK code outsidecompat.pyuses only libadwaita 1.1 API. scripts/gui_smoke.py: builds every GUI state (on, empty, off, single-radio card, CLI unavailable, narrow window, dialogs) against a fake backend on a headless display, and drives the tray icon over D-Bus against a fake tray (menu layout, start/stop, Settings, close-to-tray, Quit) (Broadway, or Xvfb where GTK lacks Broadway), with GTK criticals made fatal. It saves a screenshot of each view. CI runs it on Ubuntu 22.04, Debian 12, Ubuntu 24.04, Fedora and Arch.
Run make test, make coverage or make gui-smoke. CI enforces ≥ 94 %
coverage of apsta_cli and the GUI's non-GTK modules. GTK widget code is
covered by the smoke test instead.