diff --git a/CONTEXT.md b/CONTEXT.md index 20944ea..195a06d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,6 +110,18 @@ The share of airtime the Region allows this device to transmit. When it's used u The App that runs the console's commands on the device itself, and shows what the console prints. Trusted like the USB port, not like the network. _Avoid_: terminal, command line, REPL +**Tunnel**: +The WireGuard connection to one server, over whatever Wi-Fi the device is on. It carries either everything or the one subnet the device's address in it belongs to. Wanted or not is the user's switch; up or not depends on Wi-Fi, the clock and the server. +_Avoid_: VPN connection, link, session + +**Sharing**: +Serving the SD card as a web page to a browser on the same network, for as long as the Storage App's Share screen is open, to whoever typed the code that screen shows. +_Avoid_: file server, web server, FTP, upload mode + +**Screenshot**: +The screen as a PNG in `/screenshots`, taken with Fn+p on any screen or the Shell's `screenshot`. Not the Debug Console's `screenshot`, which sends the screen to a PC. +_Avoid_: capture (a **Capture** is radio packets), screen grab + **Help panel**: The list of the keys that work on the screen you are on, opened with Fn+h anywhere (or `?` outside Text Entry). Each App answers for its current state; no screen names keys any other way, except the first-start Setup. _Avoid_: hints, cheat sheet, shortcuts bar @@ -188,6 +200,8 @@ _Avoid_: telnet, remote shell, Debug Build (there is one firmware) - Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation. - A **Firmware Update** installs an **Update File**; the new firmware runs on **Probation**, and fails back by **Rollback**. - **Rollback** covers new firmware; **Safe Mode** covers confirmed firmware that keeps crashing. +- A **Tunnel** rides on the **Wi-Fi Service**'s connection and ends with it; the next connection starts it afresh. +- **Sharing** lasts as long as its screen: leaving the **Storage App** ends it. - A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**. ## Flagged ambiguities diff --git a/README.md b/README.md index 13811a3..2f4134d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,23 @@ [![CI](https://git.twis.la/twisla/roro9stack/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [![Coverage of lib/ by the host tests](https://git.twis.la/twisla/roro9stack/raw/branch/badges/coverage.svg)](#build-and-test-local-ci) [![Latest release](https://git.twis.la/twisla/roro9stack/raw/branch/badges/release.svg)](https://git.twis.la/twisla/roro9stack/releases/latest) -A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0. +A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. Licensed GPL-3.0. The user guide, the how-tos and every release are at **[roro9stack.net](https://roro9stack.net)**. + +What it does today: + +- **LoRa Scanner:** every packet it hears, with the Meshtastic header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone. +- **GNSS:** position, sky view, tracks as GPX. +- **Gemini:** a browser, with bookmarks and pages saved for offline. +- **IRC:** over TLS, with logs on the card. +- **Wi-Fi Tools:** the networks around, sorted, filtered, logged. +- **Notes:** plain text files of any size, saved by themselves. +- **Storage:** the SD card: copy, move, rename, delete; viewers for text, hex, pictures (PNG, JPEG, BMP, GIF), tracks, captures and update files; **sharing with a phone's browser**. +- **Shell:** the firmware's commands on the device itself, with completion. +- **System:** load, tasks, memory, network, battery, live. +- **VPN:** a WireGuard tunnel. +- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot. +- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails. +- **Debug Console:** the device's console over Wi-Fi, off until switched on. - Domain language: [CONTEXT.md](CONTEXT.md) - Decisions: [docs/adr/](docs/adr/) @@ -10,6 +26,8 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262* ## On the device: one key +**Fn+p, on any screen, saves a screenshot** to `/screenshots` on the card (not on the page that shows the Debug Console's token; issue #83). + **Fn+h, on any screen, lists the keys that work there** (`?` does the same outside a text field). No screen names its keys itself (docs/milestones/U1.md). Every screen's keys are one table in `lib/core/src/app_keys.h`: the help panel shows the table of the state an App is in, and the website's key tables are generated from the same file. ## Requirements @@ -159,6 +177,12 @@ A new note has no file until something is typed; its file is then named after it **A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `.edit`, and the file itself is rewritten when the note is left, with a progress bar (about 450 KB a second). After a power cut, opening the note picks the edit up where it was saved. Saving needs room on the card for a second copy. The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only. +## VPN + +A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered. + +**What goes through it is one of two things:** everything, when AllowedIPs has `0.0.0.0/0` (and then nothing leaves the device while the server is silent), or the one subnet the device's tunnel address is in. A home network behind the server needs the first: lwIP routes by an interface's subnet or by default, nothing finer, and the import says how many ranges it can't reach. With the tunnel up the Debug Console and the Update Service answer on the tunnel address too, behind their token and their signature. It costs 63 KB of flash and under 2 KB of memory while up. + ## Shell The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise. @@ -217,6 +241,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `coredump erase` | Forgets the core dump | | `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise | | `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires | +| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug on` / `debug token ` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | | `help` | Lists the commands | diff --git a/docs/milestones/F1.md b/docs/milestones/F1.md index d4dd4d6..471efe6 100644 --- a/docs/milestones/F1.md +++ b/docs/milestones/F1.md @@ -1,6 +1,6 @@ # F1 — Files and Notes -**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after. +**Status:** in progress. Shipped: the Storage App (issue #3, **v0.9.0**), Notes (#19, **v0.10.0**), notes of any size (#47, **v0.15.0**), pictures in the Storage App (#45, **v0.16.0**), sharing the card with a browser (#88, **v0.18.0**). Not started: the card as a USB drive (#1), selecting several items (#41), finding files by name (#42), opening a `.gmi` in Gemini (#43), a table view for `.csv` (#44), search, undo and copy-paste in Notes (#48, #49, #50). **Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89). diff --git a/docs/milestones/N1.md b/docs/milestones/N1.md new file mode 100644 index 0000000..0609475 --- /dev/null +++ b/docs/milestones/N1.md @@ -0,0 +1,83 @@ +# N1 — Network tools + +**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. SSH (#2) is not started. + +**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours. + +## The WireGuard tunnel (issue #8) + +A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on. + +### Measured before deciding (2026-10-07) + +The issue asked for the libraries to be measured first. `esphome/wireguard` 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container. + +| | Cost | +|---|---| +| Flash, the library | 43 KB | +| Flash, with our service, page and commands | 63 KB | +| Static RAM | 1.2 KB | +| Heap with the tunnel up | 1.8 KB | + +- **It crashes this build as shipped.** The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (`CONFIG_LWIP_CHECK_THREAD_SAFETY`): the first `netif_add` stopped the device. Every call into it is made with the lock held, on our side; the library is not changed. +- **One address range is allowed by default;** more need `CONFIG_WIREGUARD_MAX_SRC_IPS`, set in `platformio.ini`. +- One peer, IPv4. +- The older `ciniml/WireGuard-ESP32` was last touched in 2021 and was not tried. + +### Decisions (design round 2026-10-07) + +| # | Decision | +|---|---| +| Q243 | **`esphome/wireguard`, pinned at 0.4.8,** with lwIP's lock taken around every call. | +| Q244 | **Configured by importing a standard `.conf` from the card** (`/vpn/wg0.conf`), from Settings or with `vpn import`. Nothing is typed on the device. | +| Q245 | **The private key comes in that file,** as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings **offers to delete the file**: the card comes out, and the key is in it in clear. | +| Q246 | One tunnel, one peer. | +| Q247 | **A switch, and "Start with Wi-Fi"** (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first. | +| Q248 | *Narrowed while building.* **Either everything goes through the tunnel, or one subnet does.** With `0.0.0.0/0` in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. **A home network behind the server can't be reached without the full tunnel:** lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach. | +| Q249 | *Not as planned.* **With everything through the tunnel, nothing leaves while the server is silent:** the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer. | +| Q250 | The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops. | +| Q251 | **The Debug Console and the Update Service answer over the tunnel** as they do on Wi-Fi: the console still wants its token and an update its signature. | +| Q252 | **`VPN` in the Status Bar** while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. `vpn status`, `up`, `down`, `import`, `forget`, `auto`. A Toast when it comes up and when the server stops answering. | +| Q253 | PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6. | + +### As built + +- **`lib/net/src/wg_config.h`** (host-tested, 6 tests): reads a `.conf` as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed. +- **`VpnService`** (`src/services/vpn_service`): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later. +- **The tunnel's own packets never go into the tunnel:** the library sends them on the interface that was the default when it started. +- **Connections that came in over Wi-Fi stay on Wi-Fi** with everything routed into the tunnel: a reply leaves by the interface whose address it carries. +- **`vpn up `** takes the tunnel down again by itself: for trying a configuration from afar, when a wrong one could cut the connection it was sent over. +- Settings: `VpnConfig` (the `.conf`, checked on every load) and `VpnAuto`. + +### Checks on the device (2026-10-07, against a WireGuard peer in a container) + +The test keys were made for the purpose and deleted. Two rounds: first with the device on a guest Wi-Fi that can't open connections to the machine the test peer ran on, so **the peer called the device** (`ListenPort`), which WireGuard allows either way round; then on a network where **the device called the peer**, as it normally would. + +| Check | Result | +|---|---| +| `vpn import`, then the file removed | "imported, through it 10.9.0.0/24"; the configuration survives a firmware update | +| `vpn up` | Up within seconds; `VPN` bright in the Status Bar; a Toast | +| From the peer, through the tunnel | 25 pings of 25, 1300 bytes too; the Debug Console's greeting on TCP 2323; TCP 3232 answers | +| DNS | The file's server while up (`wifi status` says `(VPN)`), DHCP's back after `vpn down`, with no reconnection | +| Everything through the tunnel | The device stays reachable over Wi-Fi; an update check's HTTPS to the release server is seen inside the tunnel at the peer | +| `vpn up 100` | Down by itself after 100 s | +| "Start with Wi-Fi", then a restart | Up by itself 40 s after the restart, once Wi-Fi and the clock were there | +| The peer silenced | After three minutes: "no answer yet", a Toast, `VPN` dim. With everything through the tunnel, an update check then fails: nothing leaves. The peer back: up again in under half a minute, and a Toast | +| `vpn forget` | "not set"; DNS as before | +| **The device calling the peer**, the server given by name, with a PresharedKey and `MTU = 1280` | Up in seconds; the peer shows the device's address and port as the endpoint; pings through it | +| One subnet: a connection the device opens to the peer's tunnel address | Seen inside the tunnel at the peer | +| Everything: a Gemini page from a public capsule | Fetched (TLS, 1,184 bytes), and seen inside the tunnel at the peer. Free memory fell to 45.9 KB at the lowest | +| Memory | 106.1 KB free before, 104.3 KB with the tunnel up, 106.2 KB after | +| Settings > VPN | The four rows, the state and "heard 66 s ago", the server, the address; no key anywhere on it | + +**Against a real server** (the maintainer's own, 2026-10-08): a configuration uploaded from a phone through the Storage App's sharing (issue #88) and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console. + +**Not checked:** from a network far from the server (the device was on the server's own network, reaching it by its public name). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime. + +### What went wrong while building it + +**The device stopped on the first try,** on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named `netif_add` and the line that called it. + +**Taking the tunnel down reconnected Wi-Fi.** The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed `vpn down` among them. The servers that were there are now simply remembered and put back. + +**"What AllowedIPs say" was more than the network stack can do.** The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which. diff --git a/docs/milestones/R1.md b/docs/milestones/R1.md index 611192d..8f54df8 100644 --- a/docs/milestones/R1.md +++ b/docs/milestones/R1.md @@ -1,6 +1,6 @@ # R1 — Releases -**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is built and checked on the device, on branch `gitea-updates`, not merged yet. The Issues App (#4) comes after. +**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). **Goal:** a tag is a release, built the same way every time and published where a device can find it. diff --git a/docs/milestones/S1.md b/docs/milestones/S1.md index 66e5ea3..82c6096 100644 --- a/docs/milestones/S1.md +++ b/docs/milestones/S1.md @@ -1,6 +1,6 @@ # S1 — System basics -**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. +**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. The **Shell** (issue #67) shipped as **v0.14.0**; the Shell in Safe Mode (#77) is not started. **Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1. diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index b24bbae..2ee23af 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -1,6 +1,6 @@ # U1 — Look and feel -**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started. +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. diff --git a/docs/milestones/W1.md b/docs/milestones/W1.md index 6ff169f..3a3ef7c 100644 --- a/docs/milestones/W1.md +++ b/docs/milestones/W1.md @@ -1,6 +1,6 @@ # W1: Website -**Status:** phases 1 to 3 (home, Install and Downloads; the user guide; how-tos and the FAQ) and the devlog are live at roro9stack.net; phase 4 (the developer docs) is in a pull request. Issue #12. +**Status:** live at roro9stack.net: the home, Install and Downloads pages, the user guide, the how-tos and the FAQ, the developer docs and the devlog (issue #12), published by CI since issue #79, with a search since issue #60. Not started: a Gemini mirror (#57), a French translation (#58), the docs of each version (#59). **Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index 2877452..26f7f9b 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -24,6 +24,7 @@ const RowDef kRows[] = { {Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"}, + {Row::Vpn, Kind::Page, "VPN"}, {Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"}, }; @@ -86,6 +87,7 @@ std::string SettingsMenu::value(int i) const { case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; + case Row::Vpn: return settings_.getString(Setting::VpnConfig).empty() ? "Not set" : settings_.getBool(Setting::VpnAuto) ? "With Wi-Fi" : "By hand"; case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off"; default: return ""; } diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index d72a1cb..41bf052 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, DebugConsole, About }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index a1e9b72..1dcff1c 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -307,6 +307,12 @@ inline constexpr KeyHelp kViewerImage[] = { {"Tab", "the file as hex"}, }; +// vpn: Settings, VPN +inline constexpr KeyHelp kVpn[] = { + {"Enter", "switch, import, forget"}, + {"; .", "up, down"}, +}; + // notes: Notes, the list inline constexpr KeyHelp kNotes[] = { {"; .", "up, down"}, diff --git a/lib/net/src/wg_config.cpp b/lib/net/src/wg_config.cpp new file mode 100644 index 0000000..90898a5 --- /dev/null +++ b/lib/net/src/wg_config.cpp @@ -0,0 +1,217 @@ +#include "wg_config.h" + +#include + +#include "ipv4.h" + +namespace roro::net { + +namespace { +std::string trim(const std::string& s) { + size_t a = s.find_first_not_of(" \t\r"), b = s.find_last_not_of(" \t\r"); + return a == std::string::npos ? "" : s.substr(a, b - a + 1); +} +std::string lower(std::string s) { + for (char& c : s) + if (c >= 'A' && c <= 'Z') c = static_cast(c + 32); + return s; +} +bool number(const std::string& s, long& out, long max) { + if (s.empty() || s.size() > 6) return false; + out = 0; + for (char c : s) { + if (c < '0' || c > '9') return false; + out = out * 10 + (c - '0'); + } + return out <= max; +} +// "10.9.0.2/24", or an address alone (then /32). False for anything else, IPv6 included. +bool range(const std::string& text, WgRange& out) { + size_t slash = text.find('/'); + long prefix = 32; + if (slash != std::string::npos && !number(text.substr(slash + 1), prefix, 32)) return false; + if (!parseIpv4(text.substr(0, slash), out.address)) return false; + out.prefix = static_cast(prefix); + return true; +} +template +void eachItem(const std::string& list, Each each) { + size_t at = 0; + while (at <= list.size()) { + size_t comma = list.find(',', at); + if (comma == std::string::npos) comma = list.size(); + std::string item = trim(list.substr(at, comma - at)); + if (!item.empty()) each(item); + at = comma + 1; + } +} +bool inRange(uint32_t address, const WgRange& r) { return (address & maskOf(r.prefix)) == (r.address & maskOf(r.prefix)); } +} // namespace + +bool validWgKey(const std::string& key) { + if (key.size() != 44 || key[43] != '=') return false; + for (size_t i = 0; i < 43; i++) { + char c = key[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/')) return false; + } + // 43 characters carry 258 bits: the last one's two low bits belong to no byte and are zero. + static const std::string kLast = "AEIMQUYcgkosw048"; + return kLast.find(key[42]) != std::string::npos; +} + +std::string parseWgConf(const std::string& text, WgConfig& out) { + WgConfig c; + enum { None, Interface, Peer, OtherPeer } section = None; + bool hasAddress = false, hasEndpoint = false, hasKeepalive = false; + int lineNo = 0; + std::string problem; + auto fail = [&](const std::string& what) { + if (problem.empty()) problem = "line " + std::to_string(lineNo) + ": " + what; + }; + for (size_t at = 0; at <= text.size() && problem.empty();) { + size_t end = text.find('\n', at); + if (end == std::string::npos) end = text.size(); + std::string line = text.substr(at, end - at); + at = end + 1; + lineNo++; + size_t hash = line.find_first_of("#;"); + if (hash != std::string::npos) line.resize(hash); + line = trim(line); + if (line.empty()) continue; + if (line[0] == '[') { + std::string name = lower(line); + if (name == "[interface]") section = Interface; + else if (name == "[peer]") section = section == Peer || section == OtherPeer ? OtherPeer : Peer; + else fail("a section this doesn't know"); + if (section == OtherPeer) fail("a second peer: this device has one tunnel to one peer"); + continue; + } + size_t eq = line.find('='); + if (eq == std::string::npos) { + fail("not a setting"); + continue; + } + std::string key = lower(trim(line.substr(0, eq))), value = trim(line.substr(eq + 1)); + long n = 0; + if (section == Interface) { + if (key == "privatekey") { + if (!validWgKey(value)) fail("PrivateKey isn't a key"); + c.privateKey = value; + } else if (key == "address") { + eachItem(value, [&](const std::string& item) { + WgRange r; + if (!hasAddress && range(item, r)) { + c.address = r.address; + c.prefix = r.prefix; + hasAddress = true; + } + }); + if (!hasAddress) fail("Address has no IPv4 address"); + } else if (key == "dns") { + int count = 0; + eachItem(value, [&](const std::string& item) { // names and IPv6 servers are left out + uint32_t ip; + if (count < 2 && parseIpv4(item, ip)) c.dns[count++] = ip; + }); + } else if (key == "mtu") { + if (!number(value, n, 1500) || n < 576) fail("MTU must be 576 to 1500"); + c.mtu = static_cast(n); + } else if (key == "listenport") { + if (!number(value, n, 65535)) fail("ListenPort must be a port"); + c.listenPort = static_cast(n); + } // Table, PostUp and the rest mean nothing here + } else if (section == Peer) { + if (key == "publickey") { + if (!validWgKey(value)) fail("PublicKey isn't a key"); + c.peerKey = value; + } else if (key == "presharedkey") { + if (!validWgKey(value)) fail("PresharedKey isn't a key"); + c.presharedKey = value; + } else if (key == "endpoint") { + size_t colon = value.rfind(':'); + if (value.empty() || value[0] == '[') fail("an IPv6 Endpoint: IPv4 or a name only"); + else if (colon == std::string::npos || colon == 0 || !number(value.substr(colon + 1), n, 65535) || n == 0) fail("Endpoint must be host:port"); + else if (value.find_first_of(" \t,/") != std::string::npos || colon > 253) fail("Endpoint must be host:port"); + else { + c.endpointHost = value.substr(0, colon); + c.endpointPort = static_cast(n); + hasEndpoint = true; + } + } else if (key == "allowedips") { + eachItem(value, [&](const std::string& item) { + WgRange r; + if (item.find(':') != std::string::npos) return; // IPv6: not routed here + if (!range(item, r)) return fail("AllowedIPs has something that isn't an address range"); + if (c.allowedCount == WgConfig::kMaxRanges) return fail("AllowedIPs: four IPv4 ranges at most"); + r.address &= maskOf(r.prefix); + c.allowed[c.allowedCount++] = r; + }); + } else if (key == "persistentkeepalive") { + if (lower(value) == "off") n = 0; + else if (!number(value, n, 65535)) fail("PersistentKeepalive must be seconds"); + c.keepalive = static_cast(n); + hasKeepalive = true; + } + } else if (section == None) { + fail("a setting before [Interface]"); + } + } + (void)hasKeepalive; + if (!problem.empty()) return problem; + if (c.privateKey.empty()) return "no PrivateKey under [Interface]"; + if (!hasAddress) return "no Address under [Interface]"; + if (c.peerKey.empty()) return "no PublicKey under [Peer]"; + if (!hasEndpoint) return "no Endpoint under [Peer]"; + if (!c.allowedCount) return "no IPv4 range in AllowedIPs"; + out = c; + return ""; +} + +std::string toWgConf(const WgConfig& c) { + std::string s = "[Interface]\nPrivateKey = " + c.privateKey + "\nAddress = " + formatIpv4(c.address) + "/" + std::to_string(c.prefix) + "\n"; + if (c.dns[0]) s += "DNS = " + formatIpv4(c.dns[0]) + (c.dns[1] ? ", " + formatIpv4(c.dns[1]) : "") + "\n"; + if (c.mtu) s += "MTU = " + std::to_string(c.mtu) + "\n"; + if (c.listenPort) s += "ListenPort = " + std::to_string(c.listenPort) + "\n"; + s += "[Peer]\nPublicKey = " + c.peerKey + "\n"; + if (!c.presharedKey.empty()) s += "PresharedKey = " + c.presharedKey + "\n"; + s += "Endpoint = " + c.endpointHost + ":" + std::to_string(c.endpointPort) + "\nAllowedIPs = "; + for (int i = 0; i < c.allowedCount; i++) s += (i ? ", " : "") + formatIpv4(c.allowed[i].address) + "/" + std::to_string(c.allowed[i].prefix); + s += "\nPersistentKeepalive = " + std::to_string(c.keepalive) + "\n"; + return s; +} + +WgRouting routingOf(const WgConfig& c) { + WgRouting r; + for (int i = 0; i < c.allowedCount; i++) + if (c.allowed[i].prefix == 0) r.full = true; + if (r.full) return r; + // The widest allowed range this device's own address is in is the interface's subnet; with + // none, the Address line's own. + r.prefix = c.prefix; + bool found = false; + for (int i = 0; i < c.allowedCount; i++) + if (inRange(c.address, c.allowed[i]) && (!found || c.allowed[i].prefix < r.prefix)) { + r.prefix = c.allowed[i].prefix; + found = true; + } + WgRange subnet{c.address, r.prefix}; + for (int i = 0; i < c.allowedCount; i++) + if (c.allowed[i].prefix < r.prefix || !inRange(c.allowed[i].address, subnet)) r.unreachable++; + return r; +} + +bool wgReaches(const WgConfig& c, uint32_t address) { + WgRouting r = routingOf(c); + if (r.full) return true; + return inRange(address, WgRange{c.address, r.prefix}); +} + +std::string describeWgRouting(const WgConfig& c) { + WgRouting r = routingOf(c); + if (r.full) return "everything"; + std::string s = formatIpv4(c.address & maskOf(r.prefix)) + "/" + std::to_string(r.prefix); + if (r.unreachable) s += ", not " + std::to_string(r.unreachable) + " other range" + (r.unreachable > 1 ? "s" : ""); + return s; +} + +} // namespace roro::net diff --git a/lib/net/src/wg_config.h b/lib/net/src/wg_config.h new file mode 100644 index 0000000..c770f0a --- /dev/null +++ b/lib/net/src/wg_config.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +// A WireGuard tunnel's configuration (issue #8, N1 Q243-Q253): read from the standard `.conf` a +// server's owner hands out, checked, and written back in a tidy form for the device's settings. +// One peer, IPv4. The keys are never put in a message: errors name the line and the field. +namespace roro::net { + +struct WgRange { + uint32_t address = 0; + int prefix = 0; +}; + +struct WgConfig { + static constexpr int kMaxRanges = 4; + + std::string privateKey, peerKey, presharedKey; // base64, as in the file; the last may be empty + uint32_t address = 0; // the tunnel's address on this device + int prefix = 32; + uint32_t dns[2] = {0, 0}; + int mtu = 0; // 0: WireGuard's 1420 + uint16_t listenPort = 0; // 0: any; a fixed one lets the peer be the one that calls + std::string endpointHost; + uint16_t endpointPort = 51820; + WgRange allowed[kMaxRanges]; + int allowedCount = 0; + int keepalive = 25; // seconds; what the file says, or 25: this device is always behind a NAT +}; + +// "" and `out` filled, or why the file can't be used ("line 7: ..."). +std::string parseWgConf(const std::string& text, WgConfig& out); +// The same configuration as a `.conf` again: what the settings keep. +std::string toWgConf(const WgConfig& config); +bool validWgKey(const std::string& key); // 32 bytes in base64 + +// What can go through the tunnel. The network stack routes by an interface's own subnet or by +// default, nothing finer: so either everything goes through it (AllowedIPs has 0.0.0.0/0), or the +// one subnet this device's tunnel address is in. Ranges that are neither can't be reached, and +// the user is told how many. +struct WgRouting { + bool full = false; // the tunnel is the default route + int prefix = 32; // of the tunnel interface, when not full + int unreachable = 0; // allowed ranges outside it +}; +WgRouting routingOf(const WgConfig& config); +bool wgReaches(const WgConfig& config, uint32_t address); // would a packet to this address go through it? + +// For the screen and the console: never a key. +std::string describeWgRouting(const WgConfig& config); + +} // namespace roro::net diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index 962b325..97bd4cc 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -2,6 +2,7 @@ #include "debug_auth.h" #include "ipv4.h" +#include "wg_config.h" namespace roro { @@ -45,6 +46,8 @@ const Definition kDefinitions[] = { {"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189) {"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token {"help_told", Kind::Bool, 0, nullptr, 0, 1}, + {"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses + {"vpn_auto", Kind::Bool, 0, nullptr, 0, 1}, }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); @@ -103,6 +106,10 @@ bool Settings::validString(Setting s, const std::string& value) const { if (s == Setting::Ntp1) return net::validHost(value); if (s == Setting::Ntp2) return value.empty() || net::validHost(value); if (s == Setting::DebugToken) return value.empty() || debug::validToken(value); + if (s == Setting::VpnConfig) { + net::WgConfig config; + return value.empty() || net::parseWgConf(value, config).empty(); + } return true; } diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 97b9bc0..1f94583 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -34,6 +34,8 @@ enum class Setting : uint8_t { DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on) DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on HelpTold, // bool: this device has been told about the help key once (issue #69, Q201) + VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8) + VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on) Count }; diff --git a/platformio.ini b/platformio.ini index 719c5fc..f4e7634 100644 --- a/platformio.ini +++ b/platformio.ini @@ -17,9 +17,11 @@ monitor_speed = 115200 build_flags = -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 + -DCONFIG_WIREGUARD_MAX_SRC_IPS=4 lib_deps = m5stack/M5Cardputer @ 1.1.1 jgromes/RadioLib @ 7.8.1 + esphome/wireguard @ 0.4.8 test_ignore = * ; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid ; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 655ec12..1b3e316 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -39,6 +39,7 @@ install Update from SD update check | update list | update status | update install the project's releases on Gitea sd card | sd list | cat | log | burst | sound on|off | short | normal Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command +vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token crash abort|wdt crash on purpose (to test crash reports and Safe Mode) @@ -109,6 +110,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `coredump erase` | Forgets the core dump | | `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise | | `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires | +| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed | | `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long | | `debug on` / `debug token ` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed | | `help` | Lists the commands | diff --git a/site/content/dev/debug/files-and-screens.md b/site/content/dev/debug/files-and-screens.md index 29d2bb0..484e2cd 100644 --- a/site/content/dev/debug/files-and-screens.md +++ b/site/content/dev/debug/files-and-screens.md @@ -8,6 +8,8 @@ tag = "Console" These are the **binary commands**: a text header line, then raw bytes. The console task answers them itself, so they keep working when the main loop is stuck. `scripts/rdbg.py` handles each one on the PC side; the protocol is given too, for your own tools. +**Without a PC,** the device does both by itself now: Fn + p saves a screenshot to the card ([how-to](/howto/screenshot/)), and w in the Storage App serves the card to a browser ([how-to](/howto/phone-files/)). What follows is the scripted way, with checksums. + ## `get`: card to PC ```sh diff --git a/site/content/dev/milestones/f1.md b/site/content/dev/milestones/f1.md index 47891eb..573bc33 100644 --- a/site/content/dev/milestones/f1.md +++ b/site/content/dev/milestones/f1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/F1.md" tag = "F1" +++ -**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after. +**Status:** in progress. Shipped: the Storage App (issue #3, **v0.9.0**), Notes (#19, **v0.10.0**), notes of any size (#47, **v0.15.0**), pictures in the Storage App (#45, **v0.16.0**), sharing the card with a browser (#88, **v0.18.0**). Not started: the card as a USB drive (#1), selecting several items (#41), finding files by name (#42), opening a `.gmi` in Gemini (#43), a table view for `.csv` (#44), search, undo and copy-paste in Notes (#48, #49, #50). **Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89). diff --git a/site/content/dev/milestones/n1.md b/site/content/dev/milestones/n1.md new file mode 100644 index 0000000..27bcb2a --- /dev/null +++ b/site/content/dev/milestones/n1.md @@ -0,0 +1,91 @@ ++++ +title = "Network tools" +description = "Reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours." +weight = 100 + +[extra] +docs = true +source = "docs/milestones/N1.md" +tag = "N1" ++++ +**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. SSH (#2) is not started. + +**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours. + +## The WireGuard tunnel (issue #8) + +A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on. + +### Measured before deciding (2026-10-07) + +The issue asked for the libraries to be measured first. `esphome/wireguard` 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container. + +| | Cost | +|---|---| +| Flash, the library | 43 KB | +| Flash, with our service, page and commands | 63 KB | +| Static RAM | 1.2 KB | +| Heap with the tunnel up | 1.8 KB | + +- **It crashes this build as shipped.** The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (`CONFIG_LWIP_CHECK_THREAD_SAFETY`): the first `netif_add` stopped the device. Every call into it is made with the lock held, on our side; the library is not changed. +- **One address range is allowed by default;** more need `CONFIG_WIREGUARD_MAX_SRC_IPS`, set in `platformio.ini`. +- One peer, IPv4. +- The older `ciniml/WireGuard-ESP32` was last touched in 2021 and was not tried. + +### Decisions (design round 2026-10-07) + +| # | Decision | +|---|---| +| Q243 | **`esphome/wireguard`, pinned at 0.4.8,** with lwIP's lock taken around every call. | +| Q244 | **Configured by importing a standard `.conf` from the card** (`/vpn/wg0.conf`), from Settings or with `vpn import`. Nothing is typed on the device. | +| Q245 | **The private key comes in that file,** as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings **offers to delete the file**: the card comes out, and the key is in it in clear. | +| Q246 | One tunnel, one peer. | +| Q247 | **A switch, and "Start with Wi-Fi"** (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first. | +| Q248 | *Narrowed while building.* **Either everything goes through the tunnel, or one subnet does.** With `0.0.0.0/0` in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. **A home network behind the server can't be reached without the full tunnel:** lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach. | +| Q249 | *Not as planned.* **With everything through the tunnel, nothing leaves while the server is silent:** the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer. | +| Q250 | The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops. | +| Q251 | **The Debug Console and the Update Service answer over the tunnel** as they do on Wi-Fi: the console still wants its token and an update its signature. | +| Q252 | **`VPN` in the Status Bar** while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. `vpn status`, `up`, `down`, `import`, `forget`, `auto`. A Toast when it comes up and when the server stops answering. | +| Q253 | PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6. | + +### As built + +- **`lib/net/src/wg_config.h`** (host-tested, 6 tests): reads a `.conf` as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed. +- **`VpnService`** (`src/services/vpn_service`): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later. +- **The tunnel's own packets never go into the tunnel:** the library sends them on the interface that was the default when it started. +- **Connections that came in over Wi-Fi stay on Wi-Fi** with everything routed into the tunnel: a reply leaves by the interface whose address it carries. +- **`vpn up `** takes the tunnel down again by itself: for trying a configuration from afar, when a wrong one could cut the connection it was sent over. +- Settings: `VpnConfig` (the `.conf`, checked on every load) and `VpnAuto`. + +### Checks on the device (2026-10-07, against a WireGuard peer in a container) + +The test keys were made for the purpose and deleted. Two rounds: first with the device on a guest Wi-Fi that can't open connections to the machine the test peer ran on, so **the peer called the device** (`ListenPort`), which WireGuard allows either way round; then on a network where **the device called the peer**, as it normally would. + +| Check | Result | +|---|---| +| `vpn import`, then the file removed | "imported, through it 10.9.0.0/24"; the configuration survives a firmware update | +| `vpn up` | Up within seconds; `VPN` bright in the Status Bar; a Toast | +| From the peer, through the tunnel | 25 pings of 25, 1300 bytes too; the Debug Console's greeting on TCP 2323; TCP 3232 answers | +| DNS | The file's server while up (`wifi status` says `(VPN)`), DHCP's back after `vpn down`, with no reconnection | +| Everything through the tunnel | The device stays reachable over Wi-Fi; an update check's HTTPS to the release server is seen inside the tunnel at the peer | +| `vpn up 100` | Down by itself after 100 s | +| "Start with Wi-Fi", then a restart | Up by itself 40 s after the restart, once Wi-Fi and the clock were there | +| The peer silenced | After three minutes: "no answer yet", a Toast, `VPN` dim. With everything through the tunnel, an update check then fails: nothing leaves. The peer back: up again in under half a minute, and a Toast | +| `vpn forget` | "not set"; DNS as before | +| **The device calling the peer**, the server given by name, with a PresharedKey and `MTU = 1280` | Up in seconds; the peer shows the device's address and port as the endpoint; pings through it | +| One subnet: a connection the device opens to the peer's tunnel address | Seen inside the tunnel at the peer | +| Everything: a Gemini page from a public capsule | Fetched (TLS, 1,184 bytes), and seen inside the tunnel at the peer. Free memory fell to 45.9 KB at the lowest | +| Memory | 106.1 KB free before, 104.3 KB with the tunnel up, 106.2 KB after | +| Settings > VPN | The four rows, the state and "heard 66 s ago", the server, the address; no key anywhere on it | + +**Against a real server** (the maintainer's own, 2026-10-08): a configuration uploaded from a phone through the Storage App's sharing (issue #88) and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console. + +**Not checked:** from a network far from the server (the device was on the server's own network, reaching it by its public name). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime. + +### What went wrong while building it + +**The device stopped on the first try,** on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named `netif_add` and the line that called it. + +**Taking the tunnel down reconnected Wi-Fi.** The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed `vpn down` among them. The servers that were there are now simply remembered and put back. + +**"What AllowedIPs say" was more than the network stack can do.** The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which. diff --git a/site/content/dev/milestones/r1.md b/site/content/dev/milestones/r1.md index 9643067..fad2694 100644 --- a/site/content/dev/milestones/r1.md +++ b/site/content/dev/milestones/r1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/R1.md" tag = "R1" +++ -**Status:** in progress. CI and signed releases on Gitea (issue #5) are in place since 2026-10-06: every tag from v0.1.0 to v0.10.0 has its release. Updates from Gitea (issue #6) is built and checked on the device, on branch `gitea-updates`, not merged yet. The Issues App (#4) comes after. +**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54). **Goal:** a tag is a release, built the same way every time and published where a device can find it. diff --git a/site/content/dev/milestones/s1.md b/site/content/dev/milestones/s1.md index 968f25e..a3ba9b8 100644 --- a/site/content/dev/milestones/s1.md +++ b/site/content/dev/milestones/s1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/S1.md" tag = "S1" +++ -**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. +**Status:** the three planned items are done: the SD driver fix in v0.6.1 (issue #21, ADR 0007), fixed IPv4 settings in v0.7.0 (issue #7), the System App in v0.8.0 (issue #11). v0.8.1 adds the resting main loop (issue #40) and the GNSS pause for the radio's noise (issue #20, still open for the 11 dB that remain). Still open in the milestone: #39, following the SD driver upstream. The **Shell** (issue #67) shipped as **v0.14.0**; the Shell in Safe Mode (#77) is not started. **Goal:** the device works on any network, the card can be trusted, and you can see what the system is doing. A side milestone, like G1. diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index bbe2f95..972878b 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/U1.md" tag = "U1" +++ -**Status:** in progress. The help key (issue #69) is merged; the website's key tables generated from the same lists (issue #72) are in a pull request. Screen recording (#17) and the rest of the milestone are not started. +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. diff --git a/site/content/dev/milestones/w1.md b/site/content/dev/milestones/w1.md index 89a92b0..960fb84 100644 --- a/site/content/dev/milestones/w1.md +++ b/site/content/dev/milestones/w1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/W1.md" tag = "W1" +++ -**Status:** phases 1 to 3 (home, Install and Downloads; the user guide; how-tos and the FAQ) and the devlog are live at roro9stack.net; phase 4 (the developer docs) is in a pull request. Issue #12. +**Status:** live at roro9stack.net: the home, Install and Downloads pages, the user guide, the how-tos and the FAQ, the developer docs and the devlog (issue #12), published by CI since issue #79, with a search since issue #60. Not started: a Gemini mirror (#57), a French translation (#58), the docs of each version (#59). **Goal:** a public home for the project at **roro9stack.net**, separate from the blog (stories) and from Gitea (developers): what it is, how to install it, how to use each App, and the docs. diff --git a/site/content/faq.md b/site/content/faq.md index 99dd0c0..b01cd74 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -53,16 +53,26 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d **The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry. +**It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on. + **This website** sets no cookies and has no analytics, loads nothing from other sites, and its server logs keep only a masked part of visitors' addresses. The Install page asks the project's own server for the latest release. ## Why does IRC disconnect when I update, or when I open a Gemini page? A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/). +## Can it use a VPN? + +Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf` to the SD card and importing it in Settings. It can carry everything, or just the VPN's own subnet. See [VPN](/guide/vpn/). + ## How do I copy files to and from my phone? In the Storage App, press w: the device serves a small web page to any browser on the same Wi-Fi. Scan the QR code it shows, type the code, and upload or download. Nothing to install. See [From a phone](/guide/storage/#from-a-phone). +## How do I take a screenshot? + +Fn + p, on any screen. The picture goes to `/screenshots` on the SD card. See [Take a screenshot](/howto/screenshot/). + ## Do I need an SD card? For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/). diff --git a/site/content/guide/_index.md b/site/content/guide/_index.md index 99ce495..2fe89a2 100644 --- a/site/content/guide/_index.md +++ b/site/content/guide/_index.md @@ -10,6 +10,8 @@ This guide says what the firmware does **today** and nothing else. Start with th **The mesh messenger is planned, not built.** The LoRa Scanner listens to Meshtastic traffic and shows it, but the device sends nothing yet: that is the next milestone and waits for a second node to test with. +**Three things work on every screen:** Fn + h for the keys, Fn + p for a screenshot, and Fn + ` to go back to the Launcher. + Looking for a recipe or a quick answer? There are [how-tos](/howto/) and a [FAQ](/faq/). Something missing or wrong? Write to the [issue tracker](https://git.twis.la/twisla/roro9stack/issues) or to contact@roro9stack.net. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index b0cb152..0ab5db3 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -4,6 +4,7 @@ description = "The keys, the Launcher, the Status Bar and what happens the first weight = 1 [extra] tag = "Start here" +screens = ["help.png"] +++ ## One key to remember @@ -48,6 +49,7 @@ A strip at the top of every screen: the name of the App on the left, and on the | `97%` | The battery (in the warning colour at 15% and under) | | `SD` | A card is in; the warning colour at 80% full | | bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC | +| `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered | | `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it | | `REC` | A GNSS Track is being recorded | | `CAP` | A LoRa capture is being recorded | diff --git a/site/content/guide/keys.md b/site/content/guide/keys.md index 6b2039a..fcad9b5 100644 --- a/site/content/guide/keys.md +++ b/site/content/guide/keys.md @@ -1,7 +1,7 @@ +++ title = "Every key" description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state." -weight = 13 +weight = 14 [extra] tag = "Reference" +++ diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index fd0e2ff..88605ed 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -1,6 +1,6 @@ +++ title = "Settings" -description = "The device's names, region, screen, sound, GNSS and Wi-Fi, and where firmware updates are found." +description = "The device's names, region, screen, sound, GNSS, Wi-Fi and VPN, and where firmware updates are found." weight = 11 [extra] tag = "Settings" @@ -22,6 +22,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t | **Wi-Fi** | The page below | | **Check for updates** | Once a day, see [Updates](/guide/updates/) | | **Firmware** | The page described in [Updates](/guide/updates/) | +| **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) | | **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you | | **About** | The firmware version, the node number, battery, memory, uptime, clock and licence | @@ -46,4 +47,4 @@ Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on What Fn + h shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. -{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "debug-console"]) }} +{{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }} diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md index 5198552..d365416 100644 --- a/site/content/guide/shell.md +++ b/site/content/guide/shell.md @@ -4,6 +4,7 @@ description = "The firmware's own commands, typed on the device: look at its sta weight = 9 [extra] tag = "Shell" +screens = ["shell.png"] +++ The firmware has a set of **commands**, made for working on it from a PC. The Shell runs them **on the device itself**: no computer, no cable, no Wi-Fi. It is the tool for the day something is wrong and you are nowhere near a desk. diff --git a/site/content/guide/storage.md b/site/content/guide/storage.md index dd9e7f0..9b8dcdb 100644 --- a/site/content/guide/storage.md +++ b/site/content/guide/storage.md @@ -4,7 +4,7 @@ description = "Browse the SD card: copy, move, rename and delete with a clipboar weight = 8 [extra] tag = "Storage" -screens = ["storage.png"] +screens = ["storage.png", "picture.png", "share.png"] +++ Storage shows what is on the SD card: each folder's entries with their size and date, folders first. Enter opens a folder, Back goes up, and s sorts by name, date or size. A folder with more than 256 entries shows the first 256 by name, and says so. @@ -62,7 +62,7 @@ Press w in the Storage App to **share the card with a browser** on th What to know: - **It runs only while that screen is open**, and the code is new each time. Five wrong codes close the door for a minute. -- **It is not encrypted.** On your own network that is the usual trade; on a network you don't trust, someone listening could read the files and the code. Inside a VPN tunnel it is protected. +- **It is not encrypted.** On your own network that is the usual trade; on a network you don't trust, someone listening could read the files and the code. Through the [VPN](/guide/vpn/) it is protected. - **One thing at a time:** while a big file is going up or down, the page waits. About 200 KB a second. - The same rules as on the device: the folders the firmware keeps for itself can't be deleted, and a folder has to be empty to be deleted from the page. - An upload is written under a temporary name and renamed when it is whole, so a transfer that is cut leaves nothing behind. diff --git a/site/content/guide/updates.md b/site/content/guide/updates.md index 8fc423e..4738310 100644 --- a/site/content/guide/updates.md +++ b/site/content/guide/updates.md @@ -1,7 +1,7 @@ +++ title = "Updates" description = "How the device updates itself from the project's releases, from the SD card or from a PC, and how it protects itself when an update goes wrong." -weight = 12 +weight = 13 [extra] tag = "Firmware" screens = ["update.png"] diff --git a/site/content/guide/vpn.md b/site/content/guide/vpn.md new file mode 100644 index 0000000..b7f44dd --- /dev/null +++ b/site/content/guide/vpn.md @@ -0,0 +1,69 @@ ++++ +title = "VPN" +description = "A WireGuard tunnel: reach your own network from any Wi-Fi, or send everything through it on a network you don't trust." +weight = 12 +[extra] +tag = "WireGuard" +screens = ["vpn.png"] ++++ + +The Cardputer can join a **WireGuard** network over whatever Wi-Fi it is on. Two uses: reaching your own machines from anywhere (an IRC bouncer, the device's own [Debug Console](/dev/debug/)), and keeping its traffic private on a hotel or café network. + +You need a WireGuard server, yours or a provider's, and the configuration file it gives a client: a `.conf`. + +## Setting it up + +1. On the server, make a configuration for a new client, as you would for a phone. +2. Copy the file to the SD card as **`/vpn/wg0.conf`**. +3. On the device: **Settings → VPN → Import /vpn/wg0.conf**. +4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear. + +If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported. + +## Using it + +**Settings → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard. + +- **The switch is for now.** It doesn't survive a restart. +- **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects. +- The tunnel waits for the clock: WireGuard needs the time. The clock is set over plain Wi-Fi first, or from GNSS. +- A [Toast](/guide/basics/#toasts) says when the tunnel comes up, and when the server stops answering. + +## What goes through it + +That depends on the `AllowedIPs` line of the file, and there are only two cases: + +| The file says | What happens | +|---|---| +| `AllowedIPs = 0.0.0.0/0` | **Everything** goes through the tunnel. While the server is silent, nothing leaves the device at all. | +| Anything else | **One subnet** goes through it: the one the device's own tunnel address is in (for `10.9.0.2` with `AllowedIPs = 10.9.0.0/24`, that is `10.9.0.x`). The rest goes out on Wi-Fi as before. | + +**A home network behind the server can only be reached with the first kind.** If the file lists `10.9.0.0/24, 192.168.1.0/24`, the second range isn't routed, and the import says so: "through it 10.9.0.0/24, not 1 other range". This is a limit of the device's network software, which can route by one subnet or by default and nothing finer. + +The DNS servers in the file are used while the tunnel is up, if they can be reached through it. + +## Being reached through it + +With the tunnel up, the device answers on its tunnel address as it does on Wi-Fi: the [Debug Console](/dev/debug/) if you switched it on (it still wants its token), and the port that receives firmware updates (they still have to be signed). + +## From the Shell + +``` +vpn status what it is doing +vpn up on, until the next restart +vpn up 120 on for two minutes, then off by itself +vpn down +vpn import reads /vpn/wg0.conf (or the path you give) +vpn forget stops it and erases its keys from the device +vpn auto on|off start with Wi-Fi +``` + +`vpn up` with a number of seconds is for trying a new configuration from a distance: if it cuts you off, it comes back by itself. + +## Limits + +One tunnel, to one server. IPv4 only: IPv6 addresses in the file are left out. The server can be an address or a name. + +## The keys, as the device lists them + +{{ keys(scopes=["vpn"]) }} diff --git a/site/content/howto/_index.md b/site/content/howto/_index.md index cd50ba9..6fa9caa 100644 --- a/site/content/howto/_index.md +++ b/site/content/howto/_index.md @@ -1,6 +1,6 @@ +++ title = "How-tos" -description = "Short recipes for things you will want to do: put a file on the card, record a track, capture radio packets, and what to try when something does not work." +description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, take a screenshot, record a track, capture radio packets, and what to try when something does not work." template = "guide-index.html" page_template = "guide-page.html" sort_by = "weight" diff --git a/site/content/howto/not-enough-memory.md b/site/content/howto/not-enough-memory.md index 5b5b2c6..0daa27d 100644 --- a/site/content/howto/not-enough-memory.md +++ b/site/content/howto/not-enough-memory.md @@ -20,6 +20,18 @@ Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or an u The [System App](/guide/system/)'s **Memory** view shows free memory, the lowest since the device started, and the largest free block, drawn against the three memory floors (55, 40 and 20 KB). Watch it fall when a connection opens, and recover when it closes. +## What the other things cost + +Small next to a secure connection, but they add up when memory is already short: + +| | While it is in use | +|---|---| +| A note open in the editor, whatever its size | 17 KB | +| Sharing the card with a browser | 13 KB | +| The Shell | 7 KB | +| The VPN tunnel | under 2 KB | +| Showing a PNG | one free block of 32 KB, while it is decoded | + ## Why it happens The Cardputer's chip has no extra memory (no PSRAM). A secure (TLS) connection costs about **52 KB** at its peak, and IRC's own connection holds about 40 KB of the 107 KB there is. A second secure connection on top does not fit, so the firmware refuses it early instead of crashing. This is also why IRC steps aside during an update, and why the daily update check waits until IRC is not connected: see [Updates](/guide/updates/). diff --git a/site/content/howto/phone-files.md b/site/content/howto/phone-files.md new file mode 100644 index 0000000..8445055 --- /dev/null +++ b/site/content/howto/phone-files.md @@ -0,0 +1,30 @@ ++++ +title = "Move files with your phone" +description = "Open the SD card in your phone's browser, over Wi-Fi: download what the device wrote, upload what it needs. Nothing to install." +weight = 9 +[extra] +tag = "SD card" ++++ + +The phone and the Cardputer must be on the **same Wi-Fi network**. + +1. On the Cardputer, open **Storage** and press w. You should see a QR code, an address and a six-digit code. +2. On the phone, **scan the QR code** with the camera and open the link. The page opens on the card's folders. (Without a camera: type the address in a browser, then the code.) +3. **To download:** tap a folder to go in, tap a file to download it. +4. **To upload:** go to the folder you want, tap **Upload files** and pick one or several. A bar shows the progress; if a file with that name is there already, the page asks before replacing it. +5. **New folder** and **Delete** do what they say. A folder must be empty to be deleted. +6. On the Cardputer, press Back. Sharing stops, and the code is no longer good. + +## What to expect + +- About **200 KB a second**: a 3 MB photo takes a quarter of a minute. +- **One thing at a time.** While a big file moves, the page waits. +- The device's screen shows how much has gone in and out, and the last thing that was asked. + +## Good to know + +- **It is not encrypted.** Use it on a network you trust, or [through the VPN](/guide/vpn/). +- Five wrong codes close it for a minute. +- It works from a computer's browser too. + +More in [Storage: From a phone](/guide/storage/#from-a-phone). diff --git a/site/content/howto/screenshot.md b/site/content/howto/screenshot.md new file mode 100644 index 0000000..7dbc76a --- /dev/null +++ b/site/content/howto/screenshot.md @@ -0,0 +1,18 @@ ++++ +title = "Take a screenshot" +description = "Save what the screen shows as a picture, look at it on the device, and get it onto your phone." +weight = 11 +[extra] +tag = "Screen" ++++ + +1. On any screen, press Fn + p. A [Toast](/guide/basics/#toasts) says "Screenshot saved in /screenshots". The Toast itself is never in the picture; a dialog or the help panel that is open is. +2. **To look at it on the device:** open **Storage**, go into `screenshots` and press Enter on the file. Enter again shows it at its own size. +3. **To get it out:** in Storage press w and [open the card in your phone's browser](/howto/phone-files/); the pictures are in `screenshots`. + +## Good to know + +- It needs an SD card. +- The files are PNGs of 240 by 135 pixels, named by date and time. +- **It refuses on Settings → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token. +- From the [Shell](/guide/shell/), `screenshot 5` takes the picture five seconds later, for a screen you can't press keys on. diff --git a/site/content/howto/sd-files.md b/site/content/howto/sd-files.md index a15bedb..dc5826d 100644 --- a/site/content/howto/sd-files.md +++ b/site/content/howto/sd-files.md @@ -6,7 +6,7 @@ weight = 2 tag = "SD card" +++ -Everything the firmware writes goes in a folder at the top of the card. Switch the Cardputer off, take the card out and read it in a computer; or look at the same folders in the [Storage App](/guide/storage/). +Everything the firmware writes goes in a folder at the top of the card. To get at it: look at the folders in the [Storage App](/guide/storage/); or [open the card in your phone's browser](/howto/phone-files/), with nothing to install; or switch the Cardputer off, take the card out and read it in a computer. | What | Where | Kind of file | |---|---|---| @@ -19,7 +19,8 @@ Everything the firmware writes goes in a folder at the top of the card. Switch t | Gemini bookmarks | `/gemini/bookmarks.gmi` | gemtext | | Files saved from Gemini that are not text | `/gemini/downloads` | whatever they were | | Update files | `/updates` | `.ota` | -| Screenshots (the Shell's `screenshot`) | `/screenshots` | `.png`, named by date and time | +| [Screenshots](/howto/screenshot/) (Fn + p) | `/screenshots` | `.png`, named by date and time | +| A VPN configuration waiting to be imported | `/vpn/wg0.conf` | you put it there; delete it once imported | ## Rules worth knowing diff --git a/site/content/howto/vpn.md b/site/content/howto/vpn.md new file mode 100644 index 0000000..1a788a6 --- /dev/null +++ b/site/content/howto/vpn.md @@ -0,0 +1,36 @@ ++++ +title = "Set up the VPN" +description = "Put a WireGuard configuration on the device with your phone, import it, and check that the tunnel is up." +weight = 10 +[extra] +tag = "VPN" ++++ + +You need a WireGuard server, and a **client configuration** made on it for the Cardputer, as you would make one for a phone: a `.conf` file. + +1. Get the `.conf` onto the phone (or a computer on the same Wi-Fi), and name it **`wg0.conf`**. +2. On the Cardputer, open **Storage** and press w; open the page on the phone ([Move files with your phone](/howto/phone-files/)). +3. In the page, tap **New folder**, name it `vpn`, go into it, and **upload `wg0.conf`**. +4. On the Cardputer, press Back to stop sharing. +5. Open **Settings → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear. +6. Switch **VPN** to On. `VPN` appears in the Status Bar, and turns bright once the server has answered, usually within seconds. The page says "It is up". +7. To have it start by itself, switch on **Start with Wi-Fi**. + +## Check it + +From another machine on the VPN, ping the Cardputer's tunnel address (the `Address` line of the file). Or on the device, in the [Shell](/guide/shell/): `vpn status`. + +## If it stays dim + +| The page says | Try | +|---|---| +| waiting for Wi-Fi | Connect to a network first | +| waiting for the clock | Give it a moment after Wi-Fi connects: the time comes from the network | +| looking up the server | The server's name doesn't resolve from this network | +| no answer yet | The server's address or port, a firewall on the way, or the keys: check the server's side has this client's public key | + +## What goes through it + +Everything, if the file says `AllowedIPs = 0.0.0.0/0`; otherwise only the VPN's own subnet. To reach your home network behind the server, you need the first. See [VPN](/guide/vpn/#what-goes-through-it). + +**The upload in step 3 is not encrypted,** and the file holds a private key: do it on a network you trust, with a key made for this device. diff --git a/site/data/apps.toml b/site/data/apps.toml index f8175e3..9400ddd 100644 --- a/site/data/apps.toml +++ b/site/data/apps.toml @@ -45,7 +45,7 @@ icon = 3 num = "06" tag = "Notes" title = "Write it down" -text = "Keep plain text notes on the SD card. There is no save key: the editor writes five seconds after you stop typing, through a temporary file, so a power cut never costs the note." +text = "Keep plain text notes on the SD card, of any size: a megabyte opens as fast as a line. There is no save key: the editor writes five seconds after you stop typing, so a power cut never costs the note." fact = "Plain .txt files in /notes" icon = 6 @@ -53,7 +53,7 @@ icon = 6 num = "07" tag = "Storage" title = "Manage the SD card" -text = "Browse, copy, cut, rename and delete with a clipboard. Copies run in the background and Back cancels one. Opens text, hex, captures, tracks and update files." +text = "Browse, copy, cut, rename and delete with a clipboard. Opens text, pictures, hex, captures, tracks and update files. Press w and the card is a web page in your phone's browser: upload and download with nothing to install." fact = "Checks every copy by size" icon = 8 diff --git a/site/data/keys.toml b/site/data/keys.toml index 6047c32..7f94e6a 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -357,6 +357,14 @@ rows = [ ["Tab", "the file as hex"], ] +[[scope]] +id = "vpn" +title = "Settings, VPN" +rows = [ + ["Enter", "switch, import, forget"], + ["; .", "up, down"], +] + [[scope]] id = "notes" title = "Notes, the list" diff --git a/site/data/screens.toml b/site/data/screens.toml index 8a20f86..af60b2c 100644 --- a/site/data/screens.toml +++ b/site/data/screens.toml @@ -38,3 +38,28 @@ caption = "Storage" file = "update.png" alt = "The full-screen progress of a firmware update: Receiving v0.10.0, a bar at 28 percent" caption = "A firmware update" + +[[screen]] +file = "shell.png" +alt = "The Shell after rm -f /gt2/*: the command in blue, then rm: 5 match /gt2/* and five lines rm: ok 1 files, above an empty input line" +caption = "Shell" + +[[screen]] +file = "picture.png" +alt = "A picture open in the Storage App: colour bars, grey and colour gradients and a yellow ellipse, shrunk to fit the screen and dithered to its 256 colours" +caption = "Storage, a picture" + +[[screen]] +file = "share.png" +alt = "The Storage App's Share screen: a QR code, the address 172.16.42.25, the code 825 132, Nothing asked yet, Not encrypted, and the key that stops sharing" +caption = "Storage, sharing with a browser" + +[[screen]] +file = "vpn.png" +alt = "Settings, VPN: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then It is up, heard 66 s ago, the server's address, and This device 10.9.0.2, through it everything. VPN shows in the Status Bar" +caption = "Settings, VPN" + +[[screen]] +file = "help.png" +alt = "The help panel over the Launcher: Keys: Launcher, up and down, Enter opens the App, then Everywhere: back, home, the arrows, Fn h for these keys and Fn p for a screenshot" +caption = "The help panel (Fn+h)" diff --git a/site/static/screens/help.png b/site/static/screens/help.png new file mode 100644 index 0000000..f483079 Binary files /dev/null and b/site/static/screens/help.png differ diff --git a/site/static/screens/picture.png b/site/static/screens/picture.png new file mode 100644 index 0000000..cd49e14 Binary files /dev/null and b/site/static/screens/picture.png differ diff --git a/site/static/screens/share.png b/site/static/screens/share.png new file mode 100644 index 0000000..4bd43f3 Binary files /dev/null and b/site/static/screens/share.png differ diff --git a/site/static/screens/shell.png b/site/static/screens/shell.png new file mode 100644 index 0000000..efee2f8 Binary files /dev/null and b/site/static/screens/shell.png differ diff --git a/site/static/screens/vpn.png b/site/static/screens/vpn.png new file mode 100644 index 0000000..46e57ea Binary files /dev/null and b/site/static/screens/vpn.png differ diff --git a/site/templates/index.html b/site/templates/index.html index d807487..4b6d6be 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -68,7 +68,7 @@ {% endfor %} -

Plus a Shell that runs the firmware's commands on the device, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the user guide.

+

Plus a Shell that runs the firmware's commands on the device, a WireGuard VPN, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the user guide.

diff --git a/site/tools/gen_dev_docs.py b/site/tools/gen_dev_docs.py index 4ef1f4d..23b98ce 100644 --- a/site/tools/gen_dev_docs.py +++ b/site/tools/gen_dev_docs.py @@ -25,7 +25,7 @@ REPO = SITE.parent OUT = SITE / "content" / "dev" REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1) -MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1"] # in the order they were done +MILESTONES = ["OTA", "M2", "G1", "M3", "S1", "F1", "R1", "W1", "U1", "N1"] # in the order they were done # Left out on purpose: docs/milestones/M0.md, M1.md and CONTEXT.md (the glossary) describe Wi-Fi monitoring, which this site does not publish. # They stay in the repository. diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 855d7b5..7763cb6 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -35,6 +35,9 @@ bool SettingsApp::onKey(const KeyEvent& e) { case Page::Firmware: if (!firmwarePage_.onKey(e)) page_ = Page::Menu; return true; + case Page::Vpn: + if (!vpnPage_.onKey(e)) page_ = Page::Menu; + return true; case Page::Debug: if (!debugPage_.onKey(e)) page_ = Page::Menu; return true; @@ -79,6 +82,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) { page_ = Page::Firmware; firmwarePage_.enter(); break; + case Row::Vpn: + page_ = Page::Vpn; + vpnPage_.enter(); + break; case Row::DebugConsole: page_ = Page::Debug; debugPage_.enter(); @@ -139,6 +146,7 @@ void SettingsApp::help(std::vector& out) const { case Page::Wifi: wifiPage_.help(out); break; case Page::Firmware: firmwarePage_.help(out); break; case Page::Debug: debugPage_.help(out); break; + case Page::Vpn: vpnPage_.help(out); break; } } @@ -147,6 +155,7 @@ const char* SettingsApp::helpTitle() const { case Page::Wifi: return wifiPage_.helpTitle(); case Page::Firmware: return firmwarePage_.helpTitle(); case Page::Debug: return debugPage_.helpTitle(); + case Page::Vpn: return "VPN"; case Page::About: return "About"; default: return nullptr; } @@ -154,7 +163,7 @@ const char* SettingsApp::helpTitle() const { void SettingsApp::update(uint32_t nowMs) { // Live values on About and Firmware. - bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug || + bool live = page_ == Page::About || page_ == Page::Firmware || page_ == Page::Debug || page_ == Page::Vpn || (page_ == Page::Wifi && wifiPage_.live()); if (live && nowMs - lastRefreshMs_ >= 500) { lastRefreshMs_ = nowMs; @@ -213,6 +222,7 @@ void SettingsApp::draw(Canvas& c) { case Page::Wifi: wifiPage_.draw(c); break; case Page::Firmware: firmwarePage_.draw(c); break; case Page::Debug: debugPage_.draw(c); break; + case Page::Vpn: vpnPage_.draw(c); break; } } diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index dde0825..b6bb51a 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -14,6 +14,7 @@ #include "services/clock_service.h" #include "services/storage_service.h" #include "apps/debug_console_page.h" +#include "apps/vpn_page.h" #include "apps/firmware_page.h" #include "apps/wifi_settings_page.h" #include "settings_menu.h" @@ -31,6 +32,7 @@ struct SettingsAppDeps { WifiService& wifi; SavedNetworks& savedNetworks; UpdateService& update; + VpnService& vpn; }; // Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages. @@ -41,7 +43,8 @@ class SettingsApp : public App { menu_(deps.settings), wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus), firmwarePage_(deps.update, deps.wifi, deps.storage), - debugPage_(deps.settings, deps.wifi) {} + debugPage_(deps.settings, deps.wifi), + vpnPage_(deps.settings, deps.vpn, deps.storage, deps.clock) {} void onEnter() override; bool onKey(const KeyEvent& e) override; void update(uint32_t nowMs) override; @@ -55,7 +58,7 @@ class SettingsApp : public App { bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token private: - enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug }; + enum class Page { Menu, Text, Choice, About, Wifi, Firmware, Debug, Vpn }; bool onMenuKey(const KeyEvent& e); bool onTextKey(const KeyEvent& e); @@ -69,6 +72,7 @@ class SettingsApp : public App { WifiSettingsPage wifiPage_; FirmwarePage firmwarePage_; DebugConsolePage debugPage_; + VpnPage vpnPage_; Page page_ = Page::Menu; ListModel list_{theme::kContent.h / theme::kLineHeight}; ListModel choices_{theme::kContent.h / theme::kLineHeight}; diff --git a/src/apps/vpn_page.cpp b/src/apps/vpn_page.cpp new file mode 100644 index 0000000..4fe5a14 --- /dev/null +++ b/src/apps/vpn_page.cpp @@ -0,0 +1,133 @@ +#include "apps/vpn_page.h" + +#include + +#include "app_keys.h" +#include "ipv4.h" +#include "ui/fonts.h" +#include "ui/theme.h" +#include "ui/widgets.h" + +namespace roro { + +void VpnPage::enter() { + list_.setCount(kRows); + confirm_.reset(); + message_.clear(); +} + +bool VpnPage::onKey(const KeyEvent& e) { + if (confirm_) { + confirm_->onKey(e); + int result = confirm_->result(); + if (result == DialogModel::kPending) return true; + Ask asked = ask_; + ask_ = Ask::None; + confirm_.reset(); + if (result == 1 && asked == Ask::DeleteFile) { + storage_.runJob([]() { SD.remove(kConfPath); }); + message_ = "Imported, and the file is deleted"; + } else if (result == 1 && asked == Ask::Forget) { + vpn_.forget(); + message_ = "Forgotten"; + } + return true; + } + switch (e.key) { + case Key::Up: list_.up(); break; + case Key::Down: list_.down(); break; + case Key::Back: return false; + case Key::Left: + case Key::Right: + case Key::Select: + if (e.key != Key::Select && list_.selected() > kAuto) break; + message_.clear(); + switch (list_.selected()) { + case kSwitch: + if (!vpn_.configured()) message_ = "Import a .conf first"; + else vpn_.want(!vpn_.wanted()); + break; + case kAuto: + if (!vpn_.configured()) message_ = "Import a .conf first"; + else settings_.setBool(Setting::VpnAuto, !settings_.getBool(Setting::VpnAuto)); + break; + case kImport: { + std::string why = vpn_.importFile(storage_, kConfPath); + if (!why.empty()) { + message_ = why; + break; + } + message_ = "Imported"; + ask_ = Ask::DeleteFile; // the card can be taken out, and the key is in that file + confirm_.reset(new DialogModel({"Keep it", "Delete it"})); + break; + } + case kForget: + if (!vpn_.configured()) break; + ask_ = Ask::Forget; + confirm_.reset(new DialogModel({"Cancel", "Forget"})); + break; + default: break; + } + break; + default: break; + } + return true; +} + +void VpnPage::help(std::vector& out) const { + if (confirm_) return keys::add(out, keys::kDialog); + keys::add(out, keys::kVpn); +} + +void VpnPage::draw(Canvas& c) { + const auto& area = theme::kContent; + c.setTextDatum(top_left); + bool set = vpn_.configured(); + widgets::list( + c, list_, {area.x, area.y, area.w, kRows * theme::kLineHeight}, + [](int i) -> std::string { + switch (i) { + case kSwitch: return "VPN"; + case kAuto: return "Start with Wi-Fi"; + case kImport: return "Import /vpn/wg0.conf"; + default: return "Forget it"; + } + }, + [&](int i) -> std::string { + switch (i) { + case kSwitch: return !set ? "Not set" : vpn_.wanted() ? "On" : "Off"; + case kAuto: return settings_.getBool(Setting::VpnAuto) ? "On" : "Off"; + default: return ""; + } + }); + + int y = area.y + kRows * theme::kLineHeight + 4; + c.setFont(&fonts::small); + auto line = [&](const std::string& text, uint16_t colour) { + c.setTextColor(colour); + c.drawString(text.c_str(), 4, y); + y += 10; + }; + if (set) { + const net::WgConfig& k = vpn_.config(); + std::string state = std::string("It is ") + vpn_.stateText(); + int64_t now = clock_.utcNow(), last = vpn_.lastHandshake(); + if (vpn_.state() == VpnService::State::Up && now >= 0 && last > 0 && now >= last) state += ", heard " + std::to_string(now - last) + " s ago"; + line(state, vpn_.state() == VpnService::State::Up ? theme::kAccent : theme::kText); + line("Server " + k.endpointHost + ":" + std::to_string(k.endpointPort), theme::kMuted); + line("This device " + net::formatIpv4(k.address) + ", through it " + net::describeWgRouting(k), theme::kMuted); + } else { + line("Copy a WireGuard .conf to the card as", theme::kMuted); + line(std::string(kConfPath) + ", then import it.", theme::kMuted); + } + if (!message_.empty()) line(message_, theme::kWarning); + else if (!vpn_.lastError().empty()) line(vpn_.lastError(), theme::kWarning); + + if (confirm_ && ask_ == Ask::DeleteFile) + widgets::dialog(c, "Delete the file?", "It is stored in the device now. The file on the card still holds the private key.", *confirm_); + else if (confirm_) + widgets::dialog(c, "Forget the VPN?", "The tunnel stops and its keys are erased from the device.", *confirm_); +} + +} // namespace roro diff --git a/src/apps/vpn_page.h b/src/apps/vpn_page.h new file mode 100644 index 0000000..0c34759 --- /dev/null +++ b/src/apps/vpn_page.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "dialog_model.h" +#include "key_event.h" +#include "key_help.h" +#include "list_model.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "services/vpn_service.h" +#include "settings.h" +#include "ui/canvas.h" + +namespace roro { + +// Settings > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card +// and forgetting it, and what the tunnel is doing. No key is ever on this page. +class VpnPage { + public: + static constexpr const char* kConfPath = "/vpn/wg0.conf"; + + VpnPage(Settings& settings, VpnService& vpn, StorageService& storage, ClockService& clock) + : settings_(settings), vpn_(vpn), storage_(storage), clock_(clock) {} + + void enter(); + bool onKey(const KeyEvent& e); // false: leave the page + void draw(Canvas& c); + void help(std::vector& out) const; + + private: + enum Row { kSwitch, kAuto, kImport, kForget, kRows }; + enum class Ask { None, DeleteFile, Forget }; + + Settings& settings_; + VpnService& vpn_; + StorageService& storage_; + ClockService& clock_; + ListModel list_{kRows}; + std::unique_ptr confirm_; + Ask ask_ = Ask::None; + std::string message_; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 9190966..a041ac7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -12,6 +12,7 @@ #include "apps/demo_app.h" #include "apps/note_editor.h" #include "apps/shell_app.h" +#include "services/vpn_service.h" #include "services/web_share.h" #include "apps/gemini_app.h" #include "apps/gnss_app.h" @@ -27,6 +28,7 @@ #include "event_bus.h" #include "file_receiver.h" #include "ipv4.h" +#include "wg_config.h" #include "traffic.h" #include "key_mapper.h" #include "platform/console.h" @@ -86,6 +88,7 @@ static WifiService* wifi; static IrcService* irc; static UpdateService* update; static DebugConsole* debugConsole; +static VpnService* vpnService; // not in Safe Mode static Notifier* notifier; static LauncherApp launcher; static AppManager* apps; @@ -132,6 +135,8 @@ static StatusInfo currentStatus() { s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening; } s.capturing = loraCapture && loraCapture->capturing(); + if (vpnService && vpnService->wanted()) + s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying; s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On; using WifiState = WifiController::State; switch (wifi->state()) { @@ -209,6 +214,8 @@ void setup() { services.add(*update); debugConsole = new DebugConsole(*wifi, *storageService, settings); services.add(*debugConsole); + vpnService = new VpnService(settings, *wifi, *clockService, bus); + services.add(*vpnService); apps = new AppManager(launcher); launcher.setManager(*apps); @@ -227,7 +234,7 @@ void setup() { apps->registerApp({"system", "System", false, new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)}); apps->registerApp({"settings", "Settings", false, - new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})}); + new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); @@ -667,6 +674,7 @@ static const char* const kHelp = "update check | update list | update status | update install the project's releases on Gitea\n" "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" "Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n" + "vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n" "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" @@ -726,6 +734,44 @@ static void saveScreenshot() { }); } +// `vpn ...` (issue #8). Nothing here prints a key. +static void vpnCommand(const String& arg) { + if (!vpnService) return (void)console.println("vpn: not available in Safe Mode"); + VpnService& v = *vpnService; + if (arg == "up" || arg.startsWith("up ")) { + if (!v.configured()) return (void)console.println("vpn: error not set: copy a .conf to /vpn/wg0.conf, then `vpn import`"); + uint32_t seconds = arg.length() > 3 ? constrain(arg.substring(3).toInt(), 0, 86400) : 0; + v.want(true, seconds); + if (seconds) console.printf("vpn: on for %lu s\n", (unsigned long)seconds); + else console.println("vpn: on"); + } else if (arg == "down") { + v.want(false); + console.println("vpn: off"); + } else if (arg == "import" || arg.startsWith("import ")) { + std::string path = arg.length() > 7 ? arg.substring(7).c_str() : "/vpn/wg0.conf"; + std::string why = v.importFile(*storageService, path); + if (!why.empty()) return (void)console.printf("vpn: error %s\n", why.c_str()); + console.printf("vpn: imported, through it %s. %s still holds the private key: `rm -f` it\n", net::describeWgRouting(v.config()).c_str(), path.c_str()); + } else if (arg == "forget") { + v.forget(); + console.println("vpn: forgotten"); + } else if (arg == "status") { + if (!v.configured()) return (void)console.println("vpn: not set"); + const net::WgConfig& k = v.config(); + console.printf("vpn: %s%s, server %s:%u, this device %s/%d, through it %s\n", v.wanted() ? "" : "off, ", v.wanted() ? v.stateText() : "configured", + k.endpointHost.c_str(), (unsigned)k.endpointPort, net::formatIpv4(k.address).c_str(), k.prefix, net::describeWgRouting(k).c_str()); + int64_t now = clockService->utcNow(), last = v.lastHandshake(); + if (last > 0 && now >= last) console.printf("vpn: last handshake %ld s ago\n", (long)(now - last)); + if (!v.lastError().empty()) console.printf("vpn: %s\n", v.lastError().c_str()); + console.printf("vpn: start with Wi-Fi %s\n", settings.getBool(Setting::VpnAuto) ? "on" : "off"); + } else if (arg == "auto on" || arg == "auto off") { + settings.setBool(Setting::VpnAuto, arg == "auto on"); + console.printf("vpn: start with Wi-Fi %s\n", arg == "auto on" ? "on" : "off"); + } else { + console.println("vpn: status | up [seconds] | down | import [path] | forget | auto on|off"); + } +} + // Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that // shows the Debug Console's token: a picture of it is a copy of the token in a file. static void screenshotKey() { @@ -820,6 +866,7 @@ static void runCommand(String line, bool fromSerial = false) { else console.println("Not now: Setup is running"); return; } + if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status")); if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial); if (line == "screenshot" || line.startsWith("screenshot ")) { uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60); @@ -1157,7 +1204,7 @@ static void runCommand(String line, bool fromSerial = false) { console.printf("wifi: address %s/%d (%s), gateway %s\n", c.address.c_str(), c.prefix, c.fixed ? "fixed" : "DHCP", c.gateway.empty() ? "none" : c.gateway.c_str()); console.printf("wifi: dns %s %s (%s)\n", c.dns[0].empty() ? "none" : c.dns[0].c_str(), c.dns[1].c_str(), - c.dnsFromSettings ? "Settings" : "DHCP"); + vpnService && vpnService->dnsThroughIt() ? "VPN" : c.dnsFromSettings ? "Settings" : "DHCP"); console.print("wifi: ntp"); for (int i = 0; i < c.ntpCount; i++) console.printf(" %s (%s%s)", c.ntp[i].server.c_str(), c.ntp[i].fromDhcp ? "DHCP" : "Settings", c.ntp[i].answered ? ", answered" : ""); console.println(c.ntpCount ? "" : " none"); diff --git a/src/services/vpn_service.cpp b/src/services/vpn_service.cpp new file mode 100644 index 0000000..d2d44a5 --- /dev/null +++ b/src/services/vpn_service.cpp @@ -0,0 +1,249 @@ +#include "services/vpn_service.h" + +#include +#include + +#include +#include +#include + +#include "ipv4.h" +#include "platform/console.h" + +namespace roro { + +namespace { +constexpr uint32_t kRetryMs = 10000; +constexpr size_t kMaxConf = 4096; + +// The library calls lwIP's raw functions and takes no lock; this build checks that the lock is +// held (CONFIG_LWIP_CHECK_THREAD_SAFETY) and stops the device when it isn't. +struct LwipLock { + LwipLock() { LOCK_TCPIP_CORE(); } + ~LwipLock() { UNLOCK_TCPIP_CORE(); } +}; +} // namespace + +struct VpnService::Tunnel { + wireguard_config_t config = ESP_WIREGUARD_CONFIG_DEFAULT(); + wireguard_ctx_t ctx = ESP_WIREGUARD_CONTEXT_DEFAULT(); + std::string address, netmask; // what `config` points into, with config_'s own strings + bool inited = false, connected = false, isDefault = false, dnsIn = false; + ip_addr_t dnsBefore[2]; +}; + +const char* VpnService::stateText() const { + switch (state_) { + case State::NoConfig: return "not set"; + case State::Off: return "off"; + case State::WaitingWifi: return "waiting for Wi-Fi"; + case State::WaitingClock: return "waiting for the clock"; + case State::Resolving: return "looking up the server"; + case State::Trying: return "no answer yet"; + case State::Up: return "up"; + } + return ""; +} + +void VpnService::loadConfig() { + const std::string& stored = settings_.getString(Setting::VpnConfig); + configured_ = !stored.empty() && net::parseWgConf(stored, config_).empty(); + if (!configured_) config_ = net::WgConfig(); +} + +void VpnService::start() { + loadConfig(); + wanted_ = configured_ && settings_.getBool(Setting::VpnAuto); + state_ = !configured_ ? State::NoConfig : State::Off; +} + +void VpnService::want(bool on, uint32_t seconds) { + wanted_ = on && configured_; + timed_ = wanted_ && seconds > 0; + untilMs_ = millis() + seconds * 1000; + retryMs_ = 0; + if (!wanted_) takeDown(); +} + +std::string VpnService::import(const std::string& confText) { + net::WgConfig fresh; + std::string why = net::parseWgConf(confText, fresh); + if (!why.empty()) return why; + if (!settings_.setString(Setting::VpnConfig, net::toWgConf(fresh))) return "it couldn't be stored"; + takeDown(); // it comes back up by itself with the new one, if it was wanted + loadConfig(); + state_ = State::Off; + return ""; +} + +std::string VpnService::importFile(StorageService& storage, const std::string& path) { + std::string text, why; + bool ran = storage.runAndWait([&]() { + File f = SD.open(path.c_str(), FILE_READ); + if (!f || f.isDirectory()) { + why = "there is no " + path; + return; + } + size_t size = f.size(); + if (size > kMaxConf) why = "that file is too big to be a .conf"; + else { + text.resize(size); + if (size && f.read(reinterpret_cast(&text[0]), size) != static_cast(size)) why = "the card refused to read it"; + } + f.close(); + }); + if (!ran) return "no SD card"; + if (!why.empty()) return why; + return import(text); +} + +void VpnService::forget() { + takeDown(); + wanted_ = false; + settings_.setString(Setting::VpnConfig, ""); + settings_.setBool(Setting::VpnAuto, false); + loadConfig(); + state_ = State::NoConfig; +} + +void VpnService::bringUp() { + if (!tunnel_) tunnel_ = new Tunnel(); + Tunnel& t = *tunnel_; + net::WgRouting routing = net::routingOf(config_); + t.address = net::formatIpv4(config_.address); + t.netmask = net::formatIpv4(net::maskOf(routing.full ? config_.prefix : routing.prefix)); + t.config.private_key = config_.privateKey.c_str(); + t.config.public_key = config_.peerKey.c_str(); + t.config.preshared_key = config_.presharedKey.empty() ? nullptr : config_.presharedKey.c_str(); + t.config.address = t.address.c_str(); + t.config.netmask = t.netmask.c_str(); + t.config.endpoint = config_.endpointHost.c_str(); + t.config.port = config_.endpointPort; + t.config.listen_port = config_.listenPort; + t.config.persistent_keepalive = static_cast(config_.keepalive); + + LwipLock lock; + esp_err_t err = ESP_OK; + if (!t.inited) { + err = esp_wireguard_init(&t.config, &t.ctx); + t.inited = err == ESP_OK; + } + if (err == ESP_OK) err = esp_wireguard_connect(&t.ctx); + if (err == ESP_ERR_RETRY) { // the server's name isn't resolved yet: asked again at the next tick + state_ = State::Resolving; + return; + } + if (err == ESP_OK) { + // What may come out of the tunnel, and with "everything", where every packet now goes. The + // tunnel's own packets don't: the library sends them on the interface it started on. + for (int i = 0; i < config_.allowedCount && err == ESP_OK; i++) { + std::string address = net::formatIpv4(config_.allowed[i].address), mask = net::formatIpv4(net::maskOf(config_.allowed[i].prefix)); + err = esp_wireguard_add_allowed_ip(&t.ctx, address.c_str(), mask.c_str()); + } + } + if (err != ESP_OK) { + error_ = std::string("the tunnel couldn't start (") + esp_err_to_name(err) + ")"; + console.printf("vpn: error %s\n", error_.c_str()); + esp_wireguard_disconnect(&t.ctx); + t = Tunnel(); + retryMs_ = millis() + kRetryMs; + state_ = State::Trying; + return; + } + if (routing.full) t.isDefault = esp_wireguard_set_default(&t.ctx) == ESP_OK; + if (config_.mtu && t.ctx.netif) t.ctx.netif->mtu = static_cast(config_.mtu); + // The file's DNS servers, if they can be reached through the tunnel at all. + if (config_.dns[0] && net::wgReaches(config_, config_.dns[0])) { + t.dnsIn = true; + for (int i = 0; i < 2; i++) ip_addr_set_any(false, &t.dnsBefore[i]); + keepDns(); + } + t.connected = true; + error_.clear(); + announced_ = false; + lastHandshake_ = 0; + state_ = State::Trying; + console.printf("vpn: started, %s:%u, through it %s\n", config_.endpointHost.c_str(), (unsigned)config_.endpointPort, net::describeWgRouting(config_).c_str()); +} + +bool VpnService::dnsThroughIt() const { return tunnel_ && tunnel_->dnsIn; } + +// With lwIP's lock held. What is found in the two slots, if it isn't the tunnel's, is what goes +// back when the tunnel stops: so a DHCP renewal while it is up is not lost. +void VpnService::keepDns() { + Tunnel& t = *tunnel_; + for (int i = 0; i < 2; i++) { + ip_addr_t wanted; + ip_addr_set_zero_ip4(&wanted); + if (config_.dns[i]) ip_addr_set_ip4_u32(&wanted, lwip_htonl(config_.dns[i])); + const ip_addr_t* now = dns_getserver(static_cast(i)); + if (ip_addr_cmp(now, &wanted)) continue; + t.dnsBefore[i] = *now; + dns_setserver(static_cast(i), &wanted); + } +} + +void VpnService::takeDown() { + if (tunnel_) { + Tunnel& t = *tunnel_; + bool dns = t.dnsIn; + { + LwipLock lock; + if (t.dnsIn) + for (int i = 0; i < 2; i++) dns_setserver(static_cast(i), &t.dnsBefore[i]); + if (t.isDefault) esp_wireguard_restore_default(&t.ctx); + if (t.inited) esp_wireguard_disconnect(&t.ctx); + } + delete tunnel_; + tunnel_ = nullptr; + if (dns) wifi_.holdDns(false); + console.println("vpn: stopped"); + } + lastHandshake_ = 0; + state_ = !configured_ ? State::NoConfig : State::Off; +} + +void VpnService::tick(uint32_t nowMs) { + if (timed_ && static_cast(nowMs - untilMs_) >= 0) want(false); + if (!configured_ || !wanted_) { + if (tunnel_) takeDown(); + return; + } + // A tunnel doesn't outlive the network it was started on: the next one starts it afresh. + if (wifi_.state() != WifiController::State::Connected) { + if (tunnel_) takeDown(); + state_ = State::WaitingWifi; + return; + } + if (clock_.utcNow() < 0) { + state_ = State::WaitingClock; + return; + } + if (!tunnel_ || !tunnel_->connected) { + if (retryMs_ && static_cast(nowMs - retryMs_) < 0) return; + retryMs_ = 0; + bringUp(); + if (tunnel_ && tunnel_->dnsIn) wifi_.holdDns(true); + return; + } + bool up; + time_t last = 0; + { + LwipLock lock; + up = esp_wireguard_peer_is_up(&tunnel_->ctx) == ESP_OK; + esp_wireguard_latest_handshake(&tunnel_->ctx, &last); + if (tunnel_->dnsIn) keepDns(); + } + if (last > 0) lastHandshake_ = static_cast(last); + State was = state_; + state_ = up ? State::Up : State::Trying; + if (state_ == State::Up && !announced_) { + announced_ = true; + bus_.publish(Event::withText(EventType::Notification, ("VPN up: " + config_.endpointHost).c_str(), static_cast(NotificationLevel::Info))); + } else if (was == State::Up && state_ == State::Trying) { + announced_ = false; + bus_.publish(Event::withText(EventType::Notification, "VPN: the server stopped answering", static_cast(NotificationLevel::Warning))); + } +} + +} // namespace roro diff --git a/src/services/vpn_service.h b/src/services/vpn_service.h new file mode 100644 index 0000000..a39b454 --- /dev/null +++ b/src/services/vpn_service.h @@ -0,0 +1,74 @@ +#pragma once + +#include + +#include "event_bus.h" +#include "service.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "services/wifi_service.h" +#include "settings.h" +#include "wg_config.h" + +namespace roro { + +// The WireGuard tunnel (issue #8, docs/milestones/N1.md): one peer, IPv4, over whatever Wi-Fi the +// device is on. The protocol is the `esphome/wireguard` library's; this decides when the tunnel +// is up, takes lwIP's lock around every call into it (the library takes none), and puts the +// tunnel's DNS servers in and out. +// +// It starts once Wi-Fi is connected and the clock is set: a handshake carries the time, and a +// server refuses one older than the last it saw from this key. +class VpnService : public Service { + public: + enum class State { NoConfig, Off, WaitingWifi, WaitingClock, Resolving, Trying, Up }; + + VpnService(Settings& settings, WifiService& wifi, ClockService& clock, EventBus& bus) + : settings_(settings), wifi_(wifi), clock_(clock), bus_(bus) {} + const char* name() const override { return "vpn"; } + void start() override; + void stop() override { takeDown(); } + void tick(uint32_t nowMs) override; + + State state() const { return state_; } + const char* stateText() const; + bool configured() const { return configured_; } + const net::WgConfig& config() const { return config_; } // its keys are for the library only + bool wanted() const { return wanted_; } + // On or off, until the next restart; `seconds`: on for that long, then off by itself (for + // trying a configuration from afar, when a wrong one would cut the connection it was sent over). + void want(bool on, uint32_t seconds = 0); + + // A `.conf`'s text, or the file itself. "" or why it wasn't taken. A tunnel that is up starts + // again with the new one. + std::string import(const std::string& confText); + std::string importFile(StorageService& storage, const std::string& path); + void forget(); + + bool dnsThroughIt() const; // the tunnel's DNS servers are the ones in use + int64_t lastHandshake() const { return lastHandshake_; } // UTC seconds, 0: none yet + const std::string& lastError() const { return error_; } + + private: + struct Tunnel; // the library's structures, kept out of this header + + void bringUp(); + void takeDown(); + void loadConfig(); + void keepDns(); // puts the tunnel's servers back in if a DHCP renewal replaced them + + Settings& settings_; + WifiService& wifi_; + ClockService& clock_; + EventBus& bus_; + net::WgConfig config_; + bool configured_ = false, wanted_ = false, announced_ = false; + State state_ = State::NoConfig; + Tunnel* tunnel_ = nullptr; + uint32_t untilMs_ = 0, retryMs_ = 0; + bool timed_ = false; + int64_t lastHandshake_ = 0; + std::string error_; +}; + +} // namespace roro diff --git a/src/services/wifi_service.cpp b/src/services/wifi_service.cpp index 5c14496..407c065 100644 --- a/src/services/wifi_service.cpp +++ b/src/services/wifi_service.cpp @@ -52,6 +52,14 @@ void WifiService::ipSettingChanged(const std::string& ssid) { controller_.retryNow(millis()); } +void WifiService::holdDns(bool held) { + if (dnsHeld_ == held) return; + dnsHeld_ = held; + // Whoever held them puts back what it found (DHCP's servers can't be asked for again without + // a new lease, which would drop every connection); ours are checked right away. + if (!held) applyServers(Why::Check); +} + // DNS and NTP as decided in Q108 and Q110. Run when connected, when a setting changes, and now // and then: a DHCP renewal puts DHCP's DNS back and clears the NTP slots it didn't fill. void WifiService::applyServers(Why why) { @@ -61,7 +69,9 @@ void WifiService::applyServers(Why why) { bool wasFromSettings = dnsFromSettings_; dnsFromSettings_ = fixed_ || settings_.getBool(Setting::DnsAlways); - if (dnsFromSettings_) { + if (dnsHeld_) { + // a tunnel's servers are in: see holdDns() + } else if (dnsFromSettings_) { IPAddress dns1 = toIp(settings_.getString(Setting::Dns1)), dns2 = toIp(settings_.getString(Setting::Dns2)); if (WiFi.dnsIP(0) != dns1 || WiFi.dnsIP(1) != dns2) WiFi.setDNS(dns1, dns2); } else if (why == Why::SettingsChanged && wasFromSettings) { diff --git a/src/services/wifi_service.h b/src/services/wifi_service.h index f5029c2..8487d56 100644 --- a/src/services/wifi_service.h +++ b/src/services/wifi_service.h @@ -56,6 +56,8 @@ class WifiService : public Service { void ipSettingChanged(const std::string& ssid); // The DNS or NTP settings changed: use them now. void serversChanged() { applyServers(Why::SettingsChanged); } + // While a tunnel has put its own DNS servers in (issue #8), ours are not put back over them. + void holdDns(bool held); // The noise self-test switches the radio off for a few seconds. Not saved anywhere: a restart // during the test brings Wi-Fi back, which a changed setting wouldn't. void debugPause(bool paused) { paused_ = paused; } @@ -89,6 +91,7 @@ class WifiService : public Service { bool paused_ = false; // Debug Builds: off for a moment, whatever the setting says bool fixed_ = false; // the network in use has a Fixed address bool dnsFromSettings_ = false; + bool dnsHeld_ = false; std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here uint32_t serversCheckedMs_ = 0; }; diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index 3a5f8fe..086b288 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -48,6 +48,11 @@ void statusBar(Canvas& c, const StatusInfo& info) { case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break; case StatusInfo::Wifi::None: break; } + switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered + case StatusInfo::Vpn::Trying: right("VPN", kMuted); break; + case StatusInfo::Vpn::Up: right("VPN", kAccent); break; + case StatusInfo::Vpn::Off: break; + } switch (info.debug) { // Q190: there while the console listens, bright with someone connected case StatusInfo::Debug::On: right("DBG", kMuted); break; case StatusInfo::Debug::Client: right("DBG", kAccent); break; diff --git a/src/ui/widgets.h b/src/ui/widgets.h index 4058584..33289a9 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -31,12 +31,13 @@ struct StatusInfo { enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes bool capturing = false; // a LoRa Capture is recording (Q97) enum class Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190) + enum class Vpn { Off, Trying, Up } vpn = Vpn::Off; // the WireGuard tunnel (issue #8, Q252) bool operator==(const StatusInfo& o) const { return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi && wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites && - tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug; + tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn; } bool operator!=(const StatusInfo& o) const { return !(*this == o); } }; diff --git a/test/test_wg_config/test_wg_config.cpp b/test/test_wg_config/test_wg_config.cpp new file mode 100644 index 0000000..9521f1e --- /dev/null +++ b/test/test_wg_config/test_wg_config.cpp @@ -0,0 +1,162 @@ +#include + +#include + +#include "ipv4.h" +#include "wg_config.h" + +using namespace roro::net; + +void setUp() {} +void tearDown() {} + +namespace { +// Keys made for these tests: they open nothing. +const char* const kPriv = "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/aBcDE="; +const char* const kPub = "h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2YzE="; +const char* const kPsk = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +std::string conf(const std::string& allowed = "10.9.0.0/24", const std::string& more = "") { + return std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.9.0.2/24\nDNS = 10.9.0.1\n" + more + "\n[Peer]\nPublicKey = " + kPub + + "\nEndpoint = vpn.example.org:51820\nAllowedIPs = " + allowed + "\n"; +} +uint32_t ip(const char* text) { + uint32_t v = 0; + TEST_ASSERT_TRUE(parseIpv4(text, v)); + return v; +} +} // namespace + +void test_a_usual_file() { + WgConfig c; + TEST_ASSERT_EQUAL_STRING("", parseWgConf(conf(), c).c_str()); + TEST_ASSERT_EQUAL_STRING(kPriv, c.privateKey.c_str()); + TEST_ASSERT_EQUAL_STRING(kPub, c.peerKey.c_str()); + TEST_ASSERT_EQUAL_UINT32(ip("10.9.0.2"), c.address); + TEST_ASSERT_EQUAL_INT(24, c.prefix); + TEST_ASSERT_EQUAL_UINT32(ip("10.9.0.1"), c.dns[0]); + TEST_ASSERT_EQUAL_UINT32(0, c.dns[1]); + TEST_ASSERT_EQUAL_STRING("vpn.example.org", c.endpointHost.c_str()); + TEST_ASSERT_EQUAL_UINT16(51820, c.endpointPort); + TEST_ASSERT_EQUAL_INT(1, c.allowedCount); + TEST_ASSERT_EQUAL_INT(25, c.keepalive); // none given: this device is behind a NAT + TEST_ASSERT_EQUAL_INT(0, c.mtu); + TEST_ASSERT_TRUE(c.presharedKey.empty()); +} + +void test_as_people_write_them() { + std::string text = std::string("# my phone's old config\r\n[interface]\r\n privatekey=") + kPriv + + " ; secret\r\nAddress = fd00::2/64, 192.168.77.5\r\nDNS = dns.example, 2001:db8::1, 9.9.9.9, 1.1.1.1, 8.8.8.8\r\nMTU = 1280\r\nListenPort = 51820\r\n" + "PostUp = iptables -A FORWARD\r\n\r\n[PEER]\r\nPublicKey = " + kPub + "\r\nPresharedKey = " + kPsk + + "\r\nEndpoint = 203.0.113.9:4500\r\nAllowedIPs = 0.0.0.0/0, ::/0\r\nPersistentKeepalive = 0\r\n"; + WgConfig c; + TEST_ASSERT_EQUAL_STRING("", parseWgConf(text, c).c_str()); + TEST_ASSERT_EQUAL_UINT32(ip("192.168.77.5"), c.address); // the IPv4 one, and alone it is a /32 + TEST_ASSERT_EQUAL_INT(32, c.prefix); + TEST_ASSERT_EQUAL_UINT32(ip("9.9.9.9"), c.dns[0]); // names and IPv6 left out, two kept + TEST_ASSERT_EQUAL_UINT32(ip("1.1.1.1"), c.dns[1]); + TEST_ASSERT_EQUAL_INT(1280, c.mtu); + TEST_ASSERT_EQUAL_UINT16(51820, c.listenPort); + TEST_ASSERT_EQUAL_STRING(kPsk, c.presharedKey.c_str()); + TEST_ASSERT_EQUAL_STRING("203.0.113.9", c.endpointHost.c_str()); + TEST_ASSERT_EQUAL_UINT16(4500, c.endpointPort); + TEST_ASSERT_EQUAL_INT(1, c.allowedCount); + TEST_ASSERT_EQUAL_INT(0, c.allowed[0].prefix); + TEST_ASSERT_EQUAL_INT(0, c.keepalive); // said so +} + +void test_it_survives_being_stored() { + WgConfig a, b; + TEST_ASSERT_EQUAL_STRING("", parseWgConf(conf("10.9.0.0/24, 192.168.1.77/24", std::string("MTU = 1300\nListenPort = 4242\n")), a).c_str()); + a.presharedKey = kPsk; + std::string stored = toWgConf(a); + TEST_ASSERT_EQUAL_STRING("", parseWgConf(stored, b).c_str()); + TEST_ASSERT_EQUAL_STRING(stored.c_str(), toWgConf(b).c_str()); + TEST_ASSERT_EQUAL_UINT32(ip("192.168.1.0"), b.allowed[1].address); // a range is kept as its network + TEST_ASSERT_EQUAL_INT(1300, b.mtu); + TEST_ASSERT_EQUAL_UINT16(4242, b.listenPort); + TEST_ASSERT_EQUAL_STRING(kPsk, b.presharedKey.c_str()); +} + +void test_what_is_refused_and_why() { + WgConfig c; + c.endpointHost = "untouched"; + auto why = [&](const std::string& text) { return parseWgConf(text, c); }; + TEST_ASSERT_EQUAL_STRING("no PrivateKey under [Interface]", why("[Interface]\nAddress = 10.0.0.2/24\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 2: PrivateKey isn't a key", why("[Interface]\nPrivateKey = tooshort=\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 3: Address has no IPv4 address", why(std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = fd00::2/64\n").c_str()); + std::string base = std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.0.0.2/24\n[Peer]\nPublicKey = " + kPub + "\n"; + TEST_ASSERT_EQUAL_STRING("no Endpoint under [Peer]", why(base + "AllowedIPs = 10.0.0.0/24\n").c_str()); + TEST_ASSERT_EQUAL_STRING("no IPv4 range in AllowedIPs", why(base + "Endpoint = a.example:1\nAllowedIPs = ::/0\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 6: an IPv6 Endpoint: IPv4 or a name only", why(base + "Endpoint = [2001:db8::1]:51820\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 6: Endpoint must be host:port", why(base + "Endpoint = vpn.example.org\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 6: AllowedIPs has something that isn't an address range", why(base + "AllowedIPs = 10.0.0.0/33\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 6: AllowedIPs: four IPv4 ranges at most", why(base + "AllowedIPs = 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24, 10.0.3.0/24, 10.0.4.0/24\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 8: a second peer: this device has one tunnel to one peer", + why(base + "Endpoint = a.example:1\nAllowedIPs = 10.0.0.0/24\n[Peer]\nPublicKey = " + kPub + "\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 1: a setting before [Interface]", why("PrivateKey = x\n").c_str()); + TEST_ASSERT_EQUAL_STRING("line 4: MTU must be 576 to 1500", why(std::string("[Interface]\nPrivateKey = ") + kPriv + "\nAddress = 10.0.0.2\nMTU = 9000\n").c_str()); + TEST_ASSERT_EQUAL_STRING("untouched", c.endpointHost.c_str()); // a refused file changes nothing + // No message carries a key. + std::string bad = std::string("[Interface]\nPrivateKey = ") + kPriv + "x\n"; + TEST_ASSERT_TRUE(why(bad).find("aBcD") == std::string::npos); +} + +void test_keys() { + TEST_ASSERT_TRUE(validWgKey(kPriv)); + TEST_ASSERT_TRUE(validWgKey(kPub)); + TEST_ASSERT_FALSE(validWgKey("")); + TEST_ASSERT_FALSE(validWgKey(std::string(kPub).substr(0, 43))); + TEST_ASSERT_FALSE(validWgKey(std::string(kPub).substr(0, 43) + "A")); // no padding + TEST_ASSERT_FALSE(validWgKey("h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2Yz!=")); // not base64 + TEST_ASSERT_FALSE(validWgKey("h+vdhuWJykaesw515qrYYGNdg2pGvE7JU5PXIAV2YzF=")); // bits past the 32nd byte +} + +void test_what_goes_through_it() { + WgConfig c; + parseWgConf(conf("10.9.0.0/24"), c); + WgRouting r = routingOf(c); + TEST_ASSERT_FALSE(r.full); + TEST_ASSERT_EQUAL_INT(24, r.prefix); + TEST_ASSERT_EQUAL_INT(0, r.unreachable); + TEST_ASSERT_TRUE(wgReaches(c, ip("10.9.0.1"))); + TEST_ASSERT_FALSE(wgReaches(c, ip("10.9.1.1"))); + TEST_ASSERT_FALSE(wgReaches(c, ip("93.184.216.34"))); + TEST_ASSERT_EQUAL_STRING("10.9.0.0/24", describeWgRouting(c).c_str()); + + parseWgConf(conf("0.0.0.0/0"), c); + TEST_ASSERT_TRUE(routingOf(c).full); + TEST_ASSERT_TRUE(wgReaches(c, ip("93.184.216.34"))); + TEST_ASSERT_EQUAL_STRING("everything", describeWgRouting(c).c_str()); + + // A home network behind the server can't be reached without the full tunnel: said, not hidden. + parseWgConf(conf("10.9.0.0/24, 192.168.1.0/24"), c); + r = routingOf(c); + TEST_ASSERT_EQUAL_INT(24, r.prefix); + TEST_ASSERT_EQUAL_INT(1, r.unreachable); + TEST_ASSERT_FALSE(wgReaches(c, ip("192.168.1.10"))); + TEST_ASSERT_EQUAL_STRING("10.9.0.0/24, not 1 other range", describeWgRouting(c).c_str()); + + // The widest allowed range that holds this device's address is the tunnel's subnet. + parseWgConf(conf("10.0.0.0/8"), c); + TEST_ASSERT_EQUAL_INT(8, routingOf(c).prefix); + TEST_ASSERT_TRUE(wgReaches(c, ip("10.200.3.4"))); + TEST_ASSERT_EQUAL_INT(0, routingOf(c).unreachable); + + // Only the server itself allowed: the Address line's own subnet, and that one host outside it. + parseWgConf(conf("172.16.5.1/32"), c); + r = routingOf(c); + TEST_ASSERT_EQUAL_INT(24, r.prefix); + TEST_ASSERT_EQUAL_INT(1, r.unreachable); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_a_usual_file); + RUN_TEST(test_as_people_write_them); + RUN_TEST(test_it_survives_being_stored); + RUN_TEST(test_what_is_refused_and_why); + RUN_TEST(test_keys); + RUN_TEST(test_what_goes_through_it); + return UNITY_END(); +}