|
| 1 | +# Pirania system overview |
| 2 | + |
| 3 | +This document explains how Pirania is organized, what each component does, and how the captive-portal flow works in both voucher and read-for-access modes. It is based on the current code under `packages/pirania/`. |
| 4 | + |
| 5 | +## 1. What Pirania is |
| 6 | + |
| 7 | +Pirania is a captive portal for OpenWrt/LibreMesh nodes. It controls Internet access by MAC address and provides two access modes: |
| 8 | + |
| 9 | +- **Voucher mode**: users must enter a voucher code; the voucher is then bound to their device MAC. |
| 10 | +- **Read-for-access mode**: users view a portal page and wait a short countdown; their MAC is temporarily authorized. |
| 11 | + |
| 12 | +The authorization list is stored locally and synchronized into nftables rules so that authorized devices bypass the portal. |
| 13 | + |
| 14 | +## 2. High-level architecture |
| 15 | + |
| 16 | +``` |
| 17 | +Client device |
| 18 | + -> nftables rules (captures DNS/HTTP/HTTPS for unauthorized MACs) |
| 19 | + -> local DNS on port 59053 (pirania-dnsmasq) |
| 20 | + -> local HTTP redirect on port 59080 (pirania-uhttpd) |
| 21 | + -> portal pages on /www/portal/ |
| 22 | + -> CGI handlers authorize MAC |
| 23 | + -> captive-portal update (refresh nftables MAC set) |
| 24 | + -> normal Internet access |
| 25 | +``` |
| 26 | + |
| 27 | +The central behavior is implemented by: |
| 28 | + |
| 29 | +- `packages/pirania/files/usr/bin/captive-portal` |
| 30 | +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` |
| 31 | +- `packages/pirania/files/etc/init.d/pirania-uhttpd` |
| 32 | +- `packages/pirania/files/www/pirania-redirect/redirect` |
| 33 | +- `packages/pirania/files/usr/lib/lua/portal/portal.lua` |
| 34 | + |
| 35 | +## 3. Configuration (UCI) |
| 36 | + |
| 37 | +The main configuration file is `packages/pirania/files/etc/config/pirania`. |
| 38 | + |
| 39 | +Key options in `base_config`: |
| 40 | + |
| 41 | +- `enabled`: whether the portal is active at boot |
| 42 | +- `with_vouchers`: toggle voucher vs read-for-access mode |
| 43 | +- `portal_domain`: domain used for portal URLs (default `thisnode.info`) |
| 44 | +- `url_auth`, `url_authenticated`, `url_info`, `url_fail`: portal page paths |
| 45 | +- `db_path`: voucher database directory (JSON files) |
| 46 | +- `hooks_path`: directory for hook scripts (e.g., shared-state sync) |
| 47 | +- `allowlist_ipv4`, `allowlist_ipv6`: ranges that bypass the captive portal |
| 48 | +- `allowlist_ipv4_url_insecure`: escape hatch to retry downloaded IPv4 allowlists without TLS certificate validation; currently enabled by default for compatibility, but should only be used where TLS validation is known to be broken |
| 49 | + |
| 50 | +Access-mode options live in `config access_mode 'read_for_access'`: |
| 51 | + |
| 52 | +- `url_portal`: path to the read-for-access page |
| 53 | +- `duration_m`: authorization duration in minutes |
| 54 | + |
| 55 | +## 4. Services and startup |
| 56 | + |
| 57 | +- `packages/pirania/files/etc/init.d/pirania` starts the portal if enabled and runs hooks. |
| 58 | +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` runs a dedicated dnsmasq on port 59053. |
| 59 | +- `packages/pirania/files/etc/init.d/pirania-uhttpd` runs a small uhttpd on port 59080. |
| 60 | +- `packages/pirania/files/etc/uci-defaults/90-captive-portal-cron` installs a cron job to refresh nftables every 10 minutes. |
| 61 | + |
| 62 | +## 5. Traffic capture (nftables) |
| 63 | + |
| 64 | +`packages/pirania/files/usr/bin/captive-portal` sets up nftables rules in the `inet pirania` table: |
| 65 | + |
| 66 | +- Creates sets for authorized MACs (`pirania-auth-macs`) and allowlisted IPv4/IPv6 destination ranges. |
| 67 | +- Redirects DNS (UDP/53) to port 59053 for unauthorized MACs. |
| 68 | +- Redirects HTTP (TCP/80) to port 59080 for unauthorized MACs. |
| 69 | +- Lets HTTPS (TCP/443) pass prerouting and rejects unauthorized traffic with a TCP reset in the input/forward filter path. |
| 70 | +- Allows traffic for MACs in `pirania-auth-macs`, and allows allowlisted destinations to bypass redirects and HTTPS blocking. |
| 71 | + |
| 72 | +Authorized MACs come from `packages/pirania/files/usr/bin/pirania_authorized_macs`, which delegates to the Lua portal library and returns either voucher-based or read-for-access MACs. |
| 73 | + |
| 74 | +## 6. DNS hijack |
| 75 | + |
| 76 | +`packages/pirania/files/etc/init.d/pirania-dnsmasq` starts a dnsmasq instance that: |
| 77 | + |
| 78 | +- Answers `thisnode.info` with the node IP. |
| 79 | +- Uses shared-state hosts from `/var/hosts/shared-state-dnsmasq_hosts`. |
| 80 | +- Sends unknown domains to a fallback IP (1.2.3.4). |
| 81 | + |
| 82 | +This ensures the portal domain resolves locally when the user is captured. |
| 83 | + |
| 84 | +## 7. HTTP redirect service |
| 85 | + |
| 86 | +`packages/pirania/files/etc/init.d/pirania-uhttpd` starts an HTTP server on port 59080 serving `packages/pirania/files/www/pirania-redirect/redirect`. |
| 87 | + |
| 88 | +The redirect script: |
| 89 | + |
| 90 | +- Builds a `prev` URL from the original request. |
| 91 | +- Picks the portal entry point based on `with_vouchers`: |
| 92 | + - Voucher mode: `base_config.url_auth` |
| 93 | + - Read-for-access mode: `read_for_access.url_portal` |
| 94 | +- Sends a 302 redirect to `http://<portal_domain><path>?prev=<original>`. |
| 95 | + |
| 96 | +## 8. Portal pages and assets |
| 97 | + |
| 98 | +Static portal pages are under `packages/pirania/files/www/portal/`: |
| 99 | + |
| 100 | +- `auth.html` (voucher entry) |
| 101 | +- `info.html` (waiting/info screen) |
| 102 | +- `authenticated.html` (success) |
| 103 | +- `fail.html` (error) |
| 104 | +- `read_for_access.html` (non-voucher flow) |
| 105 | + |
| 106 | +Portal content (title, text, logo, colors) is stored in `packages/pirania/files/etc/pirania/portal.json`. The Lua module `packages/pirania/files/usr/lib/lua/portal/portal.lua` can read/write this content and also synchronize it via shared-state (`pirania_persistent`). |
| 107 | + |
| 108 | +## 9. Voucher subsystem |
| 109 | + |
| 110 | +Voucher logic is implemented in `packages/pirania/files/usr/lib/lua/voucher/` and exposed via the CLI `packages/pirania/files/usr/bin/voucher`. |
| 111 | + |
| 112 | +Key files: |
| 113 | + |
| 114 | +- `vouchera.lua`: main voucher model and operations (create, activate, invalidate, list, status checks). |
| 115 | +- `store.lua`: JSON file storage (`db_path/<id>.json`). |
| 116 | +- `config.lua`: reads `db_path`, `hooks_path`, pruning settings. |
| 117 | +- `hooks.lua`: executes hook scripts under `hooks_path/<action>/` on database changes. |
| 118 | +- `utils.lua`: URL parsing and IP/MAC lookup via ARP/neigh tables. |
| 119 | + |
| 120 | +Voucher lifecycle: |
| 121 | + |
| 122 | +1. **Create**: `voucher add` calls `vouchera.create`, which writes a JSON file and triggers `hooks.run('db_change')`. |
| 123 | +2. **Activate**: voucher code is bound to a MAC and `captive-portal update` refreshes nftables. |
| 124 | +3. **Invalidate**: sets `invalidation_date`, keeping the record for pruning; also refreshes nftables if needed. |
| 125 | +4. **Prune**: old expired/invalidated vouchers are removed when `vouchera.init()` runs. |
| 126 | + |
| 127 | +The CLI wraps these operations in `packages/pirania/files/usr/bin/voucher`. |
| 128 | + |
| 129 | +## 10. Read-for-access subsystem |
| 130 | + |
| 131 | +Read-for-access mode uses: |
| 132 | + |
| 133 | +- `packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua` |
| 134 | +- `packages/pirania/files/usr/lib/lua/read_for_access/cgi_handlers.lua` |
| 135 | + |
| 136 | +MACs are stored in `/tmp/pirania/read_for_access/auth_macs` with an expiration timestamp (based on system uptime). When a user completes the portal wait, their MAC is added and `captive-portal update` refreshes nftables. |
| 137 | + |
| 138 | +## 11. CGI endpoints |
| 139 | + |
| 140 | +Portal pages call CGI scripts under `packages/pirania/files/www/cgi-bin/pirania/`: |
| 141 | + |
| 142 | +- `preactivate_voucher`: validates voucher and either redirects to `info.html` (JS flow) or activates immediately (no-JS flow). |
| 143 | +- `activate_voucher`: final activation endpoint, binds voucher to MAC. |
| 144 | +- `authorize_mac`: used by read-for-access to authorize a MAC for a limited time. |
| 145 | +- `client_ip`: legacy endpoint that references old modules and is not used by current voucher flow. |
| 146 | + |
| 147 | +## 12. Ubus/rpcd API |
| 148 | + |
| 149 | +The ubus service is implemented in `packages/pirania/files/usr/libexec/rpcd/pirania` and exposed via ACLs in `packages/pirania/files/usr/share/rpcd/acl.d/pirania.json`. |
| 150 | + |
| 151 | +Supported calls include: |
| 152 | + |
| 153 | +- `get_portal_config`, `set_portal_config`, `disable` |
| 154 | +- `show_url`, `change_url` |
| 155 | +- `add_vouchers`, `list_vouchers`, `invalidate`, `rename` |
| 156 | +- `get_portal_page_content`, `set_portal_page_content` |
| 157 | + |
| 158 | +These are consumed by Lime-App or other management tools. |
| 159 | + |
| 160 | +## 13. Tests |
| 161 | + |
| 162 | +Pirania tests live under `packages/pirania/tests/` and cover portal flows, voucher logic, rpcd handlers, and CGI helpers. |
| 163 | + |
| 164 | +## 14. End-to-end flow summary |
| 165 | + |
| 166 | +Voucher mode: |
| 167 | + |
| 168 | +1. User hits an external site; DNS/HTTP are redirected to Pirania. |
| 169 | +2. User lands on `auth.html` and submits a voucher code. |
| 170 | +3. `preactivate_voucher` checks the code; if valid, the user waits on `info.html` (JS flow) and then calls `activate_voucher`. |
| 171 | +4. Voucher binds to MAC and nftables set is refreshed. |
| 172 | +5. User is redirected to the original URL or `authenticated.html`. |
| 173 | + |
| 174 | +Read-for-access mode: |
| 175 | + |
| 176 | +1. User hits an external site; DNS/HTTP are redirected to Pirania. |
| 177 | +2. User lands on `read_for_access.html` and waits the countdown. |
| 178 | +3. `authorize_mac` stores the MAC with a short TTL and refreshes nftables. |
| 179 | +4. User is redirected to the original URL or `authenticated.html`. |
| 180 | + |
| 181 | +## 15. Notes and caveats |
| 182 | + |
| 183 | +- The current implementation uses **nftables** (not iptables) via `captive-portal`. |
| 184 | +- `catch_interfaces` and `catch_bridged_interfaces` are applied to nftables rules: L3 interfaces via `iifname` matching in the inet table, L2 bridged interfaces via bridge-family marking. |
| 185 | +- **Enforcement is per-node.** Each node applies its own nftables rules independently. The voucher database is shared across the mesh via `shared-state-pirania`, but traffic capture and filtering happen only on nodes where Pirania is enabled. Clients connecting to a node with Pirania disabled will bypass voucher enforcement entirely, even if other nodes in the mesh have it active. For consistent mesh-wide access control, ensure Pirania is enabled on **all** client-facing nodes. |
| 186 | +- The `client_ip` CGI script depends on legacy modules (`voucher.logic`, `voucher.db`) and is non-functional (see #1249). |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +This document describes the current state of the code. For CLI usage and examples, see `Readme.md`. |
0 commit comments