Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b71aace4f | ||
|
|
7223147f26 | ||
|
|
01a8e2a233 | ||
|
|
0498916740 | ||
|
|
298407b5cf | ||
|
|
9808013fc0 | ||
|
|
9b6457d1ec | ||
|
|
a8f267416b | ||
|
|
ed7abdcaf5 | ||
|
|
86172c0342 | ||
|
|
e00fff670f | ||
|
|
4404dd9380 |
@@ -110,6 +110,26 @@ 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
|
||||
|
||||
**Session**:
|
||||
The one SSH connection to a shell on another machine, from login until either side ends it. It belongs to the SSH Service, not to the SSH App: it goes on while another App is in front.
|
||||
_Avoid_: connection, tunnel, terminal (the terminal is what draws it)
|
||||
|
||||
**Device Key**:
|
||||
The Ed25519 key pair the device makes for itself to log in over SSH. The private half never leaves the device and is never shown; the public half is meant to be copied to servers.
|
||||
_Avoid_: identity, SSH key file, certificate
|
||||
|
||||
**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
|
||||
@@ -181,6 +201,7 @@ _Avoid_: telnet, remote shell, Debug Build (there is one firmware)
|
||||
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
||||
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
||||
- The **Wi-Fi Service** is either Connected or Monitoring, never both. Monitoring pauses the **IRC Service**, which reconnects and rejoins its **Buffers** afterwards.
|
||||
- The SSH App draws the one **Session**; the **Session** and the **IRC Service**'s connection don't fit in memory together, so each waits for the other.
|
||||
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
||||
- The **Radio Service** owns the radio; the **Mesh Service** and the LoRa Scanner use it.
|
||||
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
||||
@@ -188,6 +209,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
|
||||
|
||||
@@ -2,7 +2,24 @@
|
||||
|
||||
[](https://git.twis.la/twisla/roro9stack/actions?workflow=ci.yml) [](#build-and-test-local-ci) [](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, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
||||
- **System:** load, tasks, memory, network, battery, live.
|
||||
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
||||
- **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 +27,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,9 +178,21 @@ 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, `<note>.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.
|
||||
|
||||
## SSH
|
||||
|
||||
The SSH App (docs/milestones/N1.md, issue #2) is a terminal on another machine: one session to a shell, over libssh (`ewpa/LibSSH-ESP32`) on mbedTLS. `user@host[:port]`, typed in the App or as `ssh user@host` in the Shell; up to eight hosts are remembered. **A server is trusted the first time, on its fingerprint, and a changed key is a warning** whose selected answer is Cancel. **The password is typed each time and kept nowhere;** or the device makes itself an Ed25519 key (kept in the device, never shown, no passphrase) whose public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`, for a server's `authorized_keys`.
|
||||
|
||||
The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, `nano` and `vim` send: the cursor, erasing, sixteen colours, scroll regions, the alternate screen; no mouse. Five text sizes with Ctrl and + or -, from 60 x 20 to 26 x 8, told to the far end; 100 lines of scrollback with Alt and up or down. The backtick key sends Esc. **Leaving the App doesn't end the session:** `SSH` shows in the Status Bar and the App finds it again. It costs 292 KB of flash (109 KB of it one table, for signing with the device's key) and about 50 KB of memory while a session is open, so it isn't started under 75 KB free and IRC doesn't connect while one is open.
|
||||
|
||||
## 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.
|
||||
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`, `Ssh`, `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. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp` and `netstat` (issue #90).
|
||||
|
||||
## Development aids
|
||||
|
||||
@@ -217,6 +248,11 @@ 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 |
|
||||
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
|
||||
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
|
||||
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
|
||||
| `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 <value>` / `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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# N1 — Network tools
|
||||
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) is built and waits for its release.
|
||||
|
||||
**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 <seconds>`** 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 put on the card by the maintainer 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.
|
||||
|
||||
## Network troubleshooting commands (issue #90)
|
||||
|
||||
With a tunnel, fixed addresses and a file server on the device, "is it the network or is it me" needed another machine to answer.
|
||||
|
||||
### Decisions (2026-10-08; built on the issue's list, without a round of questions)
|
||||
|
||||
- **The familiar names:** `ping`, `nslookup`, `traceroute`, `ifconfig`, `arp`. `port <host> <port>` for "is that TCP port open", which has no single familiar name.
|
||||
- **In this version:** those six. **Not yet:** `tls` (why a certificate fails), `ntp` (the clock's offset), `netstat` (what listens). The issue stays open for them.
|
||||
- **Commands only,** in the Shell and over both consoles; no page in an App.
|
||||
- **One line an answer, short:** a Shell line is 38 characters.
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/net/src/net_probe.h`** (host-tested, 5 tests): what was typed; the ICMP echo request and what answers it, a router's "time exceeded" included; the DNS query and its answer, with the pointers names are shortened by.
|
||||
- **`NetTools`** (`src/services/net_tools`): `ping`, `nslookup`, `port` and `traceroute` each run on a task of their own, made for the command and gone after it, printing to the console that asked (the Shell shows only its own replies). One at a time; `cancel` stops it within a fifth of a second.
|
||||
- **`ping`** and **`traceroute`** share a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.
|
||||
- **`nslookup`** asks one server itself, over UDP, and so can say which server answered and how long it took, which the system's resolver doesn't; and it can ask a server that isn't the configured one.
|
||||
- **`port`** is a connection attempt that is not waited for: open, refused, or five seconds of nothing.
|
||||
- **`ifconfig`** and **`arp`** read lwIP's own lists, with its lock held.
|
||||
- **Cost:** 12 KB of flash. A 6 KB task while a command runs (2.6 KB of it never used), nothing otherwise.
|
||||
|
||||
### Checks on the device (2026-10-08, with the VPN up and everything routed through it)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ifconfig` | `vpn 10.9.0.2/32 mtu 1420, up, default route`; `wifi ... gw ... mtu 1500, up`; the DNS server |
|
||||
| `arp` | The gateway and one other machine |
|
||||
| `ping` of a neighbour, of a name | 4 of 4 in 3 to 4 ms; 3 of 3 in about 50 ms |
|
||||
| `ping 9.9.9.9 2 1392`, then `1393` | Both back; neither back: the tunnel carries 1420 bytes exactly |
|
||||
| `nslookup` | The address, the server and the time; an alias followed; with another server; "there is no nope.invalid" |
|
||||
| `port` | `open, 52 ms`; `refused`; "no answer in 5 s"; "doesn't resolve" |
|
||||
| `traceroute 9.9.9.9` | Nine hops, the tunnel's server first, "arrived" |
|
||||
| A second command while a ping runs | "another one is running: `cancel` stops it" |
|
||||
| `cancel` | "stopped", with the count so far |
|
||||
| In the Shell | Tab completes them; the lines appear there and only there |
|
||||
|
||||
**Not checked:** without the VPN (every check went through the tunnel, or to the local network); a network that drops ICMP; the commands in Safe Mode, where they are not offered.
|
||||
|
||||
**Found on the way:** a refused connection is reported by lwIP as "reset", not "refused"; the first version called it "no route". And the header for the tested half was first given the same name as the service's, which makes a file include itself: the same mistake as an hour before, in the same way.
|
||||
|
||||
### The rest of the list: `tls`, `ntp`, `netstat` (2026-10-08)
|
||||
|
||||
- **`tls <host> [port]`** makes a handshake that checks nothing, so that a bad certificate can be looked at, and then checks it itself: against this device's roots (`ca_roots.h`, the ones the Update Service trusts) and the name asked for. It says who the certificate is for, who signed it, from when to when with the days left, the verdict with its reasons, and the SHA-256 that a Gemini pin is. It runs on a 12 KB task and isn't tried with less than 70 KB free: a handshake peaks at about 52 KB.
|
||||
- **`ntp [server]`** sends one SNTP request and compares the answer with the device's clock, allowing for half the round trip. With no server it asks the first one in Settings.
|
||||
- **`netstat`** reads lwIP's own lists: what listens, labelled where the firmware knows what it is, what is connected, and the UDP ports in use.
|
||||
- Host tests: the NTP packet and the year 2036, the offset in words, a certificate's name (an old string type that mbedTLS prints as hex included), days between dates. 7 tests in `test/test_net_probe` in all.
|
||||
|
||||
| Check on the device | Result |
|
||||
|---|---|
|
||||
| `tls git.twis.la` | 709 ms; for git.twis.la, 67 days left, "this device trusts it", the SHA-256 |
|
||||
| `tls geminiprotocol.net 1965` | "NOT trusted here: not signed by a root this device has": a capsule signs its own |
|
||||
| `tls expired.badssl.com` | "EXPIRED 4197 days ago" |
|
||||
| `tls wrong.host.badssl.com` | "NOT trusted here: not for that name" |
|
||||
| `tls` to a port that isn't TLS | "no handshake ... An invalid SSL record was received" |
|
||||
| `ntp` | The server, its stratum, 50 ms away; "this clock is right, to 0.1 s" |
|
||||
| `netstat` | The update port and the Debug Console listening, the console's own connection, the UDP ports |
|
||||
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||
|
||||
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||
|
||||
## The SSH client (issue #2)
|
||||
|
||||
A terminal on another machine: one session to a shell, from the SSH App.
|
||||
|
||||
### Measured before deciding (2026-10-08)
|
||||
|
||||
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||
|
||||
| | Measured in the trial | As built |
|
||||
|---|---|---|
|
||||
| Flash | 120 KB | **292 KB** |
|
||||
| Static RAM | 1.2 KB | |
|
||||
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||
| Lowest free heap during a login | | 30 KB |
|
||||
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||
|
||||
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||
|
||||
### Decisions (design round 2026-10-08)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||
|
||||
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||
| Forgetting a host | Asked, then gone from the list |
|
||||
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||
|
||||
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -307,6 +307,40 @@ inline constexpr KeyHelp kViewerImage[] = {
|
||||
{"Tab", "the file as hex"},
|
||||
};
|
||||
|
||||
// vpn: Settings, VPN
|
||||
inline constexpr KeyHelp kVpn[] = {
|
||||
{"Enter", "switch, import, forget"},
|
||||
{"; .", "up, down"},
|
||||
};
|
||||
|
||||
// ssh: SSH, the hosts
|
||||
inline constexpr KeyHelp kSsh[] = {
|
||||
{"Enter", "connect, open"},
|
||||
{"; .", "up, down"},
|
||||
{"n", "a new connection"},
|
||||
{"d", "forget this host"},
|
||||
};
|
||||
|
||||
// ssh-terminal: SSH, the terminal
|
||||
inline constexpr KeyHelp kSshTerminal[] = {
|
||||
{"`", "Esc"},
|
||||
{"Alt `", "a backtick"},
|
||||
{"Fn ; . , /", "the arrows"},
|
||||
{"Fn Shift ; .", "Page Up, Page Down"},
|
||||
{"Ctrl a..z", "Ctrl+C and the rest"},
|
||||
{"Alt ; .", "scroll back, forward"},
|
||||
{"Ctrl + -", "larger, smaller text"},
|
||||
{"Ctrl Alt q", "disconnect"},
|
||||
{"Fn `", "leave it running"},
|
||||
};
|
||||
|
||||
// ssh-key: SSH, this device's key
|
||||
inline constexpr KeyHelp kSshKey[] = {
|
||||
{"Enter", "make a key, or a new one"},
|
||||
{"w", "write the public half to the card"},
|
||||
{"`", "back"},
|
||||
};
|
||||
|
||||
// notes: Notes, the list
|
||||
inline constexpr KeyHelp kNotes[] = {
|
||||
{"; .", "up, down"},
|
||||
|
||||
@@ -43,6 +43,15 @@ KeyEvent charEvent(uint32_t cp, const RawKeys& keys) {
|
||||
return e;
|
||||
}
|
||||
|
||||
// A key that isn't a character, with what was held: a terminal tells Alt+Enter from Enter (issue #2).
|
||||
KeyEvent keyEvent(Key key, const RawKeys& keys) {
|
||||
KeyEvent e = KeyEvent::of(key);
|
||||
e.shift = keys.shift;
|
||||
e.ctrl = keys.ctrl;
|
||||
e.alt = keys.alt;
|
||||
return e;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
@@ -51,9 +60,9 @@ std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
if (keys.opt && !previous_.opt && keys.chars.empty()) {
|
||||
compose_ = compose_ ? 0 : kArmed; // a second opt cancels
|
||||
}
|
||||
if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select));
|
||||
if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete));
|
||||
if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab));
|
||||
if (keys.enter && !previous_.enter) out.push_back(keyEvent(Key::Select, keys));
|
||||
if (keys.del && !previous_.del) out.push_back(keyEvent(Key::Delete, keys));
|
||||
if (keys.tab && !previous_.tab) out.push_back(keyEvent(Key::Tab, keys));
|
||||
|
||||
for (char c : keys.chars) {
|
||||
bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
|
||||
@@ -89,10 +98,10 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
|
||||
if (keys.fn || !textEntry_) {
|
||||
switch (c) {
|
||||
case ';': out.push_back(KeyEvent::of(Key::Up)); return;
|
||||
case '.': out.push_back(KeyEvent::of(Key::Down)); return;
|
||||
case ',': out.push_back(KeyEvent::of(Key::Left)); return;
|
||||
case '/': out.push_back(KeyEvent::of(Key::Right)); return;
|
||||
case ';': out.push_back(keyEvent(Key::Up, keys)); return;
|
||||
case '.': out.push_back(keyEvent(Key::Down, keys)); return;
|
||||
case ',': out.push_back(keyEvent(Key::Left, keys)); return;
|
||||
case '/': out.push_back(keyEvent(Key::Right, keys)); return;
|
||||
case '`':
|
||||
if (keys.fn) {
|
||||
out.push_back(KeyEvent::of(Key::Home));
|
||||
@@ -126,7 +135,7 @@ void KeyMapper::onChar(char c, const RawKeys& keys, std::vector<KeyEvent>& out)
|
||||
}
|
||||
}
|
||||
if (c == '`') {
|
||||
out.push_back(KeyEvent::of(Key::Back));
|
||||
out.push_back(keyEvent(Key::Back, keys));
|
||||
return;
|
||||
}
|
||||
out.push_back(charEvent(static_cast<unsigned char>(c), keys));
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#include "net_probe.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "ipv4.h"
|
||||
|
||||
namespace roro::net {
|
||||
|
||||
namespace {
|
||||
std::vector<std::string> words(const std::string& text) {
|
||||
std::vector<std::string> out;
|
||||
size_t at = 0;
|
||||
while (at < text.size()) {
|
||||
while (at < text.size() && text[at] == ' ') at++;
|
||||
size_t end = text.find(' ', at);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
if (end > at) out.push_back(text.substr(at, end - at));
|
||||
at = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
bool number(const std::string& s, long& out) {
|
||||
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 true;
|
||||
}
|
||||
uint16_t be16(const uint8_t* p) { return static_cast<uint16_t>((p[0] << 8) | p[1]); }
|
||||
} // namespace
|
||||
|
||||
std::string parsePing(const std::string& args, PingArgs& out) {
|
||||
static const char* const kUsage = "ping <host> [count] [size]";
|
||||
auto w = words(args);
|
||||
if (w.empty() || w.size() > 3 || !validHost(w[0])) return kUsage;
|
||||
PingArgs a;
|
||||
a.host = w[0];
|
||||
long n;
|
||||
if (w.size() > 1) {
|
||||
if (!number(w[1], n) || n < 1 || n > 100) return "a count from 1 to 100";
|
||||
a.count = static_cast<int>(n);
|
||||
}
|
||||
if (w.size() > 2) {
|
||||
if (!number(w[2], n) || n > 1400) return "a size from 0 to 1400 bytes";
|
||||
a.size = static_cast<int>(n);
|
||||
}
|
||||
out = a;
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string parsePort(const std::string& args, PortArgs& out) {
|
||||
static const char* const kUsage = "port <host> <port>";
|
||||
auto w = words(args);
|
||||
if (w.size() == 1) { // host:port
|
||||
size_t colon = w[0].rfind(':');
|
||||
if (colon == std::string::npos) return kUsage;
|
||||
w = {w[0].substr(0, colon), w[0].substr(colon + 1)};
|
||||
}
|
||||
long n;
|
||||
if (w.size() != 2 || !validHost(w[0])) return kUsage;
|
||||
if (!number(w[1], n) || n < 1 || n > 65535) return "a port from 1 to 65535";
|
||||
out.host = w[0];
|
||||
out.port = static_cast<uint16_t>(n);
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string parseLookup(const std::string& args, LookupArgs& out) {
|
||||
static const char* const kUsage = "nslookup <name> [server's address]";
|
||||
auto w = words(args);
|
||||
uint32_t ip;
|
||||
if (w.empty() || w.size() > 2 || !validHost(w[0])) return kUsage;
|
||||
if (w.size() == 2 && !parseIpv4(w[1], ip)) return "the server as an address: 9.9.9.9";
|
||||
out.name = w[0];
|
||||
out.server = w.size() == 2 ? w[1] : "";
|
||||
return "";
|
||||
}
|
||||
|
||||
uint16_t inetChecksum(const uint8_t* data, size_t len) {
|
||||
uint32_t sum = 0;
|
||||
for (size_t i = 0; i + 1 < len; i += 2) sum += static_cast<uint32_t>((data[i] << 8) | data[i + 1]);
|
||||
if (len & 1) sum += static_cast<uint32_t>(data[len - 1] << 8);
|
||||
while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
|
||||
return static_cast<uint16_t>(~sum);
|
||||
}
|
||||
|
||||
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload) {
|
||||
size_t len = 8 + payload;
|
||||
if (len > max) return 0;
|
||||
out[0] = 8; // echo request
|
||||
out[1] = 0;
|
||||
out[2] = out[3] = 0;
|
||||
out[4] = static_cast<uint8_t>(id >> 8);
|
||||
out[5] = static_cast<uint8_t>(id);
|
||||
out[6] = static_cast<uint8_t>(seq >> 8);
|
||||
out[7] = static_cast<uint8_t>(seq);
|
||||
for (size_t i = 0; i < payload; i++) out[8 + i] = static_cast<uint8_t>('a' + i % 26);
|
||||
uint16_t sum = inetChecksum(out, len);
|
||||
out[2] = static_cast<uint8_t>(sum >> 8);
|
||||
out[3] = static_cast<uint8_t>(sum);
|
||||
return len;
|
||||
}
|
||||
|
||||
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len) {
|
||||
IcmpAnswer a;
|
||||
if (len < 20 || (packet[0] >> 4) != 4) return a;
|
||||
size_t header = static_cast<size_t>(packet[0] & 0x0F) * 4;
|
||||
if (header < 20 || len < header + 8 || packet[9] != 1) return a; // not ICMP
|
||||
const uint8_t* icmp = packet + header;
|
||||
size_t left = len - header;
|
||||
if (icmp[0] == 0 && icmp[1] == 0) { // echo reply
|
||||
a.kind = IcmpAnswer::Kind::Echo;
|
||||
a.id = be16(icmp + 4);
|
||||
a.seq = be16(icmp + 6);
|
||||
return a;
|
||||
}
|
||||
if (icmp[0] != 11 && icmp[0] != 3) return a;
|
||||
// Inside: the IP header of the packet it is about, and that packet's first 8 bytes.
|
||||
if (left < 8 + 20) return a;
|
||||
const uint8_t* inner = icmp + 8;
|
||||
size_t innerHeader = static_cast<size_t>(inner[0] & 0x0F) * 4;
|
||||
if ((inner[0] >> 4) != 4 || innerHeader < 20 || left < 8 + innerHeader + 8 || inner[9] != 1 || inner[innerHeader] != 8) return a;
|
||||
a.kind = icmp[0] == 11 ? IcmpAnswer::Kind::TimeExceeded : IcmpAnswer::Kind::Unreachable;
|
||||
a.id = be16(inner + innerHeader + 4);
|
||||
a.seq = be16(inner + innerHeader + 6);
|
||||
return a;
|
||||
}
|
||||
|
||||
void PingStats::add(uint32_t ms) {
|
||||
minMs = back ? std::min(minMs, ms) : ms;
|
||||
maxMs = std::max(maxMs, ms);
|
||||
sumMs += ms;
|
||||
back++;
|
||||
}
|
||||
|
||||
std::string PingStats::summary() const {
|
||||
int lost = sent ? (sent - back) * 100 / sent : 0;
|
||||
std::string s = std::to_string(back) + "/" + std::to_string(sent) + " back, " + std::to_string(lost) + "% lost"; // short: a Shell line is 38 characters
|
||||
if (back) s += ", " + std::to_string(minMs) + "/" + std::to_string(sumMs / static_cast<uint32_t>(back)) + "/" + std::to_string(maxMs) + " ms";
|
||||
return s;
|
||||
}
|
||||
|
||||
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name) {
|
||||
if (name.empty() || name.size() > 253 || 12 + name.size() + 2 + 4 > max) return 0;
|
||||
std::memset(out, 0, 12);
|
||||
out[0] = static_cast<uint8_t>(id >> 8);
|
||||
out[1] = static_cast<uint8_t>(id);
|
||||
out[2] = 0x01; // recursion wanted
|
||||
out[5] = 1; // one question
|
||||
size_t at = 12;
|
||||
for (size_t from = 0; from <= name.size();) {
|
||||
size_t dot = name.find('.', from);
|
||||
if (dot == std::string::npos) dot = name.size();
|
||||
size_t n = dot - from;
|
||||
if (n == 0 && dot == name.size()) break; // a final dot
|
||||
if (n == 0 || n > 63) return 0;
|
||||
out[at++] = static_cast<uint8_t>(n);
|
||||
std::memcpy(out + at, name.data() + from, n);
|
||||
at += n;
|
||||
from = dot + 1;
|
||||
}
|
||||
out[at++] = 0;
|
||||
out[at++] = 0;
|
||||
out[at++] = 1; // A
|
||||
out[at++] = 0;
|
||||
out[at++] = 1; // IN
|
||||
return at;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Reads a name at `at`, following the pointers DNS shortens names with. Where the name ends in
|
||||
// the message (not where a pointer led), or 0 if it is broken.
|
||||
size_t readName(const uint8_t* m, size_t len, size_t at, std::string* out) {
|
||||
size_t end = 0;
|
||||
int jumps = 0;
|
||||
while (at < len) {
|
||||
uint8_t n = m[at];
|
||||
if (n == 0) return end ? end : at + 1;
|
||||
if ((n & 0xC0) == 0xC0) {
|
||||
if (at + 1 >= len || ++jumps > 8) return 0;
|
||||
if (!end) end = at + 2;
|
||||
at = static_cast<size_t>(((n & 0x3F) << 8) | m[at + 1]);
|
||||
continue;
|
||||
}
|
||||
if (n > 63 || at + 1 + n > len) return 0;
|
||||
if (out) {
|
||||
if (!out->empty()) *out += '.';
|
||||
out->append(reinterpret_cast<const char*>(m + at + 1), n);
|
||||
}
|
||||
at += 1 + static_cast<size_t>(n);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool parseDnsAnswer(const uint8_t* m, size_t len, uint16_t id, DnsAnswer& out) {
|
||||
if (len < 12 || be16(m) != id || !(m[2] & 0x80)) return false;
|
||||
DnsAnswer a;
|
||||
a.truncated = m[2] & 0x02;
|
||||
a.rcode = m[3] & 0x0F;
|
||||
int questions = be16(m + 4), answers = be16(m + 6);
|
||||
size_t at = 12;
|
||||
for (int i = 0; i < questions; i++) {
|
||||
at = readName(m, len, at, nullptr);
|
||||
if (!at || at + 4 > len) return false;
|
||||
at += 4;
|
||||
}
|
||||
for (int i = 0; i < answers; i++) {
|
||||
at = readName(m, len, at, nullptr);
|
||||
if (!at || at + 10 > len) return false;
|
||||
uint16_t type = be16(m + at), size = be16(m + at + 8);
|
||||
at += 10;
|
||||
if (at + size > len) return false;
|
||||
if (type == 1 && size == 4) a.addresses.push_back((static_cast<uint32_t>(m[at]) << 24) | (m[at + 1] << 16) | (m[at + 2] << 8) | m[at + 3]);
|
||||
if (type == 5) {
|
||||
std::string name;
|
||||
if (readName(m, len, at, &name)) a.alias = name;
|
||||
}
|
||||
at += size;
|
||||
}
|
||||
out = a;
|
||||
return true;
|
||||
}
|
||||
|
||||
void buildNtpRequest(uint8_t out[kNtpPacket]) {
|
||||
std::memset(out, 0, kNtpPacket);
|
||||
out[0] = 0x23; // no warning, version 4, a client
|
||||
}
|
||||
|
||||
bool parseNtpAnswer(const uint8_t* p, size_t len, NtpAnswer& out) {
|
||||
if (len < kNtpPacket || (p[0] & 0x07) != 4) return false; // not a server's
|
||||
if (p[1] == 0 || p[1] > 15) return false; // "kiss of death", or not synchronised
|
||||
uint32_t secs = (static_cast<uint32_t>(p[40]) << 24) | (p[41] << 16) | (p[42] << 8) | p[43];
|
||||
uint32_t frac = (static_cast<uint32_t>(p[44]) << 24) | (p[45] << 16) | (p[46] << 8) | p[47];
|
||||
if (!secs) return false;
|
||||
// NTP counts from 1900 and wraps in 2036: a small number is the era after.
|
||||
constexpr int64_t k1900To1970 = 2208988800LL;
|
||||
int64_t since1900 = secs < 0x80000000u ? static_cast<int64_t>(secs) + 4294967296LL : static_cast<int64_t>(secs);
|
||||
out.stratum = p[1];
|
||||
out.seconds = since1900 - k1900To1970;
|
||||
out.millis = static_cast<uint32_t>((static_cast<uint64_t>(frac) * 1000) >> 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string clockOffset(int64_t ownMs, int64_t serverMs) {
|
||||
int64_t diff = ownMs - serverMs, size = diff < 0 ? -diff : diff;
|
||||
if (size < 100) return "right, to 0.1 s";
|
||||
std::string amount = size < 10000 ? std::to_string(size / 1000) + "." + std::to_string(size % 1000 / 100) + " s"
|
||||
: size < 120000 ? std::to_string(size / 1000) + " s"
|
||||
: size < 7200000 ? std::to_string(size / 60000) + " min"
|
||||
: size < 172800000LL ? std::to_string(size / 3600000) + " h" : std::to_string(size / 86400000LL) + " days";
|
||||
return amount + (diff > 0 ? " ahead" : " behind");
|
||||
}
|
||||
|
||||
std::string certName(const std::string& dn) {
|
||||
for (const char* key : {"CN=", "O="}) {
|
||||
size_t at = 0;
|
||||
while ((at = dn.find(key, at)) != std::string::npos) {
|
||||
if (at == 0 || dn[at - 1] == ' ' || dn[at - 1] == ',') {
|
||||
size_t from = at + std::strlen(key), end = dn.find(", ", from);
|
||||
std::string name = dn.substr(from, end == std::string::npos ? std::string::npos : end - from);
|
||||
// An old kind of string comes out as "#" and hex, type and length first: read it.
|
||||
if (name.size() > 5 && name[0] == '#' && name.size() % 2 == 1) {
|
||||
std::string plain;
|
||||
for (size_t i = 5; i + 1 < name.size(); i += 2) {
|
||||
auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; };
|
||||
int hi = digit(name[i]), lo = digit(name[i + 1]);
|
||||
if (hi < 0 || lo < 0 || hi * 16 + lo < 0x20 || hi * 16 + lo > 0x7E) return name;
|
||||
plain += static_cast<char>(hi * 16 + lo);
|
||||
}
|
||||
return plain;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
at++;
|
||||
}
|
||||
}
|
||||
return dn;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Days since a fixed day long ago (the civil calendar, leap years and all).
|
||||
long dayNumber(int y, int m, int d) {
|
||||
y -= m <= 2;
|
||||
long era = (y >= 0 ? y : y - 399) / 400;
|
||||
long yoe = y - era * 400, doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
|
||||
return era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2) { return static_cast<int>(dayNumber(y2, m2, d2) - dayNumber(y1, m1, d1)); }
|
||||
|
||||
const char* portLabel(uint16_t port, bool tcp) {
|
||||
if (tcp) return port == 3232 ? "updates" : port == 2323 ? "Debug Console" : port == 80 ? "sharing" : "";
|
||||
return port == 68 ? "DHCP" : port == 123 ? "NTP" : "";
|
||||
}
|
||||
|
||||
} // namespace roro::net
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// The parts of the network troubleshooting commands (issue #90) that need no network: what was
|
||||
// asked, the packets to send, and what the answers mean. The sockets are src/services/net_tools.h (a different name on purpose: two headers of one name find themselves).
|
||||
namespace roro::net {
|
||||
|
||||
// --- what was typed
|
||||
|
||||
struct PingArgs {
|
||||
std::string host;
|
||||
int count = 4; // 1 to 100
|
||||
int size = 56; // bytes of payload, 0 to 1400: with its headers a ping is 28 more
|
||||
};
|
||||
// "ping <host> [count] [size]". "" or how to ask.
|
||||
std::string parsePing(const std::string& args, PingArgs& out);
|
||||
|
||||
struct PortArgs {
|
||||
std::string host;
|
||||
uint16_t port = 0;
|
||||
};
|
||||
// "port <host> <port>", or host:port.
|
||||
std::string parsePort(const std::string& args, PortArgs& out);
|
||||
|
||||
struct LookupArgs {
|
||||
std::string name, server; // server: an address, or "" for the one in use
|
||||
};
|
||||
std::string parseLookup(const std::string& args, LookupArgs& out);
|
||||
|
||||
// --- ICMP: ping and traceroute
|
||||
|
||||
uint16_t inetChecksum(const uint8_t* data, size_t len);
|
||||
// An echo request: 8 bytes of header and `payload` bytes after it. The length written, or 0 if
|
||||
// it doesn't fit.
|
||||
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload);
|
||||
|
||||
struct IcmpAnswer {
|
||||
enum class Kind { Other, Echo, TimeExceeded, Unreachable } kind = Kind::Other;
|
||||
uint16_t id = 0, seq = 0; // of the echo request it answers
|
||||
};
|
||||
// `packet` as a raw socket hands it over: the IP header first. A router's "time exceeded" and
|
||||
// "unreachable" carry the start of the packet they are about, which is where id and seq come from.
|
||||
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len);
|
||||
|
||||
// What a run of pings came to: "3/4 back, 25% lost, 12/25/41 ms" (the least, the mean, the most).
|
||||
struct PingStats {
|
||||
int sent = 0, back = 0;
|
||||
uint32_t minMs = 0, maxMs = 0, sumMs = 0;
|
||||
void add(uint32_t ms);
|
||||
std::string summary() const;
|
||||
};
|
||||
|
||||
// --- DNS: nslookup
|
||||
|
||||
// A query for the IPv4 addresses of `name`. The length written, or 0 if that isn't a name.
|
||||
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name);
|
||||
|
||||
struct DnsAnswer {
|
||||
int rcode = 0; // 0: fine, 3: no such name
|
||||
bool truncated = false; // the answer didn't fit in one packet
|
||||
std::vector<uint32_t> addresses;
|
||||
std::string alias; // the last name a CNAME led to, if any
|
||||
};
|
||||
// False if it isn't the answer to query `id`, or is cut short.
|
||||
bool parseDnsAnswer(const uint8_t* message, size_t len, uint16_t id, DnsAnswer& out);
|
||||
|
||||
// --- NTP: the clock's offset
|
||||
|
||||
constexpr size_t kNtpPacket = 48;
|
||||
void buildNtpRequest(uint8_t out[kNtpPacket]);
|
||||
struct NtpAnswer {
|
||||
int stratum = 0; // 1: a reference clock; 2 and up: that many steps from one
|
||||
int64_t seconds = 0; // the server's clock when it answered, UTC since 1970
|
||||
uint32_t millis = 0; // and the part of a second
|
||||
};
|
||||
// False if it isn't a server's answer, or says the server has no time to give.
|
||||
bool parseNtpAnswer(const uint8_t* packet, size_t len, NtpAnswer& out);
|
||||
// "0.3 s ahead", "12 s behind", "right, to 0.1 s": this clock against the server's, both in ms.
|
||||
std::string clockOffset(int64_t ownMs, int64_t serverMs);
|
||||
|
||||
// --- TLS: who a certificate is for
|
||||
|
||||
// The common name out of a certificate's subject or issuer as mbedTLS prints it
|
||||
// ("C=US, O=Let's Encrypt, CN=R11"): the CN, else the O, else all of it.
|
||||
std::string certName(const std::string& dn);
|
||||
// Whole days from one date to another (negative: the second is earlier).
|
||||
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2);
|
||||
|
||||
// --- netstat
|
||||
|
||||
// What listens on a port of this firmware, or "".
|
||||
const char* portLabel(uint16_t port, bool tcp);
|
||||
|
||||
} // namespace roro::net
|
||||
@@ -0,0 +1,217 @@
|
||||
#include "wg_config.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<char>(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<int>(prefix);
|
||||
return true;
|
||||
}
|
||||
template <typename Each>
|
||||
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<int>(n);
|
||||
} else if (key == "listenport") {
|
||||
if (!number(value, n, 65535)) fail("ListenPort must be a port");
|
||||
c.listenPort = static_cast<uint16_t>(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<uint16_t>(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<int>(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
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// 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
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "ipv4.h"
|
||||
#include "wg_config.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
@@ -45,6 +46,13 @@ 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},
|
||||
{"ssh_hosts", Kind::String, 0, "", 0, 800},
|
||||
{"ssh_known", Kind::String, 0, "", 0, 2400},
|
||||
{"ssh_key", Kind::String, 0, "", 0, 800},
|
||||
{"ssh_public", Kind::String, 0, "", 0, 200},
|
||||
{"ssh_font", Kind::Int, 1, nullptr, 0, 4},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
@@ -103,6 +111,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ 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)
|
||||
SshHosts, // string: the SSH App's saved hosts, one user@host[:port] a line (issue #2, ssh_hosts.h)
|
||||
SshKnown, // string: the fingerprint each server showed first, one "host:port fingerprint" a line
|
||||
SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown
|
||||
SshPublic, // string: its public half, the line to put in a server's authorized_keys
|
||||
SshFont, // int: the terminal's font, 0 (smallest) to 4
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#include "ssh_hosts.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::term {
|
||||
|
||||
namespace {
|
||||
std::vector<std::string> linesOf(const std::string& text) {
|
||||
std::vector<std::string> out;
|
||||
for (size_t at = 0; at < text.size();) {
|
||||
size_t end = text.find('\n', at);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
if (end > at) out.push_back(text.substr(at, end - at));
|
||||
at = end + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
bool hostChars(const std::string& s) {
|
||||
if (s.empty() || s.size() > 253) return false;
|
||||
for (char c : s)
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '-')) return false;
|
||||
return s.front() != '.' && s.front() != '-';
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string SshTarget::text() const { return user + "@" + host + (port == 22 ? "" : ":" + std::to_string(port)); }
|
||||
std::string SshTarget::hostPort() const { return host + ":" + std::to_string(port); }
|
||||
|
||||
std::string parseSshTarget(const std::string& text, SshTarget& out) {
|
||||
size_t at = text.find('@');
|
||||
if (at == std::string::npos || at == 0) return "user@host, please";
|
||||
SshTarget t;
|
||||
t.user = text.substr(0, at);
|
||||
std::string rest = text.substr(at + 1);
|
||||
if (t.user.size() > 32 || t.user.find_first_of(" @:/") != std::string::npos) return "that isn't a user name";
|
||||
size_t colon = rest.rfind(':');
|
||||
if (colon != std::string::npos) {
|
||||
std::string port = rest.substr(colon + 1);
|
||||
long n = 0;
|
||||
if (port.empty() || port.size() > 5) return "a port from 1 to 65535";
|
||||
for (char c : port) {
|
||||
if (c < '0' || c > '9') return "a port from 1 to 65535";
|
||||
n = n * 10 + (c - '0');
|
||||
}
|
||||
if (n < 1 || n > 65535) return "a port from 1 to 65535";
|
||||
t.port = static_cast<uint16_t>(n);
|
||||
rest.resize(colon);
|
||||
}
|
||||
if (!hostChars(rest)) return "that isn't a host";
|
||||
t.host = rest;
|
||||
out = t;
|
||||
return "";
|
||||
}
|
||||
|
||||
SshHosts::SshHosts(const std::string& stored) {
|
||||
for (auto& line : linesOf(stored)) {
|
||||
SshTarget t;
|
||||
if (hosts_.size() < kMax && parseSshTarget(line, t).empty()) hosts_.push_back(t.text());
|
||||
}
|
||||
}
|
||||
|
||||
void SshHosts::used(const SshTarget& target) {
|
||||
std::string text = target.text();
|
||||
hosts_.erase(std::remove(hosts_.begin(), hosts_.end(), text), hosts_.end());
|
||||
hosts_.insert(hosts_.begin(), text);
|
||||
if (hosts_.size() > kMax) hosts_.resize(kMax);
|
||||
}
|
||||
|
||||
void SshHosts::remove(size_t index) {
|
||||
if (index < hosts_.size()) hosts_.erase(hosts_.begin() + static_cast<long>(index));
|
||||
}
|
||||
|
||||
std::string SshHosts::stored() const {
|
||||
std::string s;
|
||||
for (auto& h : hosts_) s += h + "\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
SshKnownHosts::SshKnownHosts(const std::string& stored) {
|
||||
for (auto& line : linesOf(stored)) {
|
||||
size_t space = line.find(' ');
|
||||
if (space != std::string::npos && space > 0 && space + 1 < line.size() && known_.size() < kMax) known_.emplace_back(line.substr(0, space), line.substr(space + 1));
|
||||
}
|
||||
}
|
||||
|
||||
std::string SshKnownHosts::fingerprintOf(const std::string& hostPort) const {
|
||||
for (auto& k : known_)
|
||||
if (k.first == hostPort) return k.second;
|
||||
return "";
|
||||
}
|
||||
|
||||
void SshKnownHosts::remember(const std::string& hostPort, const std::string& fingerprint) {
|
||||
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
|
||||
known_.emplace_back(hostPort, fingerprint);
|
||||
if (known_.size() > kMax) known_.erase(known_.begin());
|
||||
}
|
||||
|
||||
void SshKnownHosts::forget(const std::string& hostPort) {
|
||||
known_.erase(std::remove_if(known_.begin(), known_.end(), [&](const std::pair<std::string, std::string>& k) { return k.first == hostPort; }), known_.end());
|
||||
}
|
||||
|
||||
std::string SshKnownHosts::stored() const {
|
||||
std::string s;
|
||||
for (auto& k : known_) s += k.first + " " + k.second + "\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Who the SSH App connects to, and which servers it has met (issue #2, N1 Q262 and Q263): kept
|
||||
// in the device's settings as a few lines of text.
|
||||
namespace roro::term {
|
||||
|
||||
struct SshTarget {
|
||||
std::string user, host;
|
||||
uint16_t port = 22;
|
||||
std::string text() const; // user@host, with :port when it isn't 22
|
||||
std::string hostPort() const; // host:port, always: what a host key is remembered under
|
||||
};
|
||||
// "user@host", "user@host:2222". "" or what is wrong with it.
|
||||
std::string parseSshTarget(const std::string& text, SshTarget& out);
|
||||
|
||||
// Up to eight, the last used first; one a line.
|
||||
class SshHosts {
|
||||
public:
|
||||
static constexpr size_t kMax = 8;
|
||||
explicit SshHosts(const std::string& stored = "");
|
||||
const std::vector<std::string>& list() const { return hosts_; }
|
||||
void used(const SshTarget& target); // to the front, added if it's new
|
||||
void remove(size_t index);
|
||||
std::string stored() const;
|
||||
|
||||
private:
|
||||
std::vector<std::string> hosts_;
|
||||
};
|
||||
|
||||
// The fingerprint each server showed the first time: "host:port SHA256:...", one a line, sixteen
|
||||
// at most (the oldest goes).
|
||||
class SshKnownHosts {
|
||||
public:
|
||||
static constexpr size_t kMax = 16;
|
||||
explicit SshKnownHosts(const std::string& stored = "");
|
||||
std::string fingerprintOf(const std::string& hostPort) const; // "" if never met
|
||||
void remember(const std::string& hostPort, const std::string& fingerprint);
|
||||
void forget(const std::string& hostPort);
|
||||
std::string stored() const;
|
||||
|
||||
private:
|
||||
std::vector<std::pair<std::string, std::string>> known_;
|
||||
};
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -0,0 +1,495 @@
|
||||
#include "terminal.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::term {
|
||||
|
||||
namespace {
|
||||
// A character the screen's font has, for one it may not.
|
||||
uint8_t glyphFor(uint32_t cp) {
|
||||
if (cp >= 0x20 && cp <= 0x7E) return static_cast<uint8_t>(cp);
|
||||
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<uint8_t>(cp);
|
||||
if (cp >= 0x2500 && cp <= 0x257F) { // box drawing
|
||||
switch (cp) {
|
||||
case 0x2500: case 0x2501: case 0x2504: case 0x2505: case 0x2508: case 0x2509: case 0x254C: case 0x254D: case 0x2550: return '-';
|
||||
case 0x2502: case 0x2503: case 0x2506: case 0x2507: case 0x250A: case 0x250B: case 0x254E: case 0x254F: case 0x2551: return '|';
|
||||
default: return '+';
|
||||
}
|
||||
}
|
||||
switch (cp) {
|
||||
case 0x2018: case 0x2019: return '\'';
|
||||
case 0x201C: case 0x201D: return '"';
|
||||
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: return '-';
|
||||
case 0x2022: case 0x25CF: return '*';
|
||||
case 0x2026: return '.';
|
||||
case 0x2190: return '<';
|
||||
case 0x2192: return '>';
|
||||
case 0x2191: return '^';
|
||||
case 0x2193: return 'v';
|
||||
case 0x2588: case 0x2593: case 0x2592: case 0x2591: return '#';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
// The DEC "special graphics" set: lines drawn with the letters j to x.
|
||||
uint8_t lineGlyph(uint8_t c) {
|
||||
switch (c) {
|
||||
case 'q': return '-';
|
||||
case 'x': return '|';
|
||||
case 'j': case 'k': case 'l': case 'm': case 'n': case 't': case 'u': case 'v': case 'w': return '+';
|
||||
case '`': return '*';
|
||||
case 'a': return '#';
|
||||
case '~': return '*';
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
// One of 256 colours, or a colour given as red, green and blue, as the nearest of the sixteen.
|
||||
int nearest16(int r, int g, int b) {
|
||||
int most = std::max(r, std::max(g, b));
|
||||
if (most < 48) return 0;
|
||||
int half = most / 2;
|
||||
int colour = (r > half ? 1 : 0) | (g > half ? 2 : 0) | (b > half ? 4 : 0);
|
||||
if (colour == 7 && most < 200) return most < 110 ? 8 : 7;
|
||||
return most > 170 ? colour | 8 : colour;
|
||||
}
|
||||
int from256(int n) {
|
||||
if (n < 16) return n;
|
||||
if (n >= 232) return nearest16(8 + (n - 232) * 10, 8 + (n - 232) * 10, 8 + (n - 232) * 10);
|
||||
n -= 16;
|
||||
static const int kSteps[6] = {0, 95, 135, 175, 215, 255};
|
||||
return nearest16(kSteps[n / 36], kSteps[(n / 6) % 6], kSteps[n % 6]);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b) {
|
||||
static const uint8_t kTable[16][3] = {{0, 0, 0}, {205, 49, 49}, {13, 188, 121}, {229, 229, 16}, {36, 114, 200}, {188, 63, 188},
|
||||
{17, 168, 205}, {204, 204, 204}, {102, 102, 102}, {241, 76, 76}, {35, 209, 139}, {245, 245, 67},
|
||||
{59, 142, 234}, {214, 112, 214}, {41, 184, 219}, {255, 255, 255}};
|
||||
r = kTable[index & 15][0];
|
||||
g = kTable[index & 15][1];
|
||||
b = kTable[index & 15][2];
|
||||
}
|
||||
|
||||
Terminal::Terminal(int cols, int rows, int historyLines)
|
||||
: cols_(std::max(2, cols)), rows_(std::max(2, rows)), historyMax_(std::max(0, historyLines)), mainGrid_(static_cast<size_t>(cols_ * rows_)),
|
||||
altGrid_(static_cast<size_t>(cols_ * rows_)), bottom_(rows_ - 1) {}
|
||||
|
||||
Cell Terminal::blank() const {
|
||||
Cell c;
|
||||
c.attr = static_cast<uint8_t>((bg_ << 4) | 7);
|
||||
return c;
|
||||
}
|
||||
|
||||
bool Terminal::takeBell() {
|
||||
bool b = bell_;
|
||||
bell_ = false;
|
||||
return b;
|
||||
}
|
||||
|
||||
std::string Terminal::rowText(int row) const {
|
||||
std::string s;
|
||||
for (int c = 0; c < cols_; c++) s += static_cast<char>(cell(row, c).ch);
|
||||
size_t end = s.find_last_not_of(' ');
|
||||
s.resize(end == std::string::npos ? 0 : end + 1);
|
||||
return s;
|
||||
}
|
||||
|
||||
int Terminal::param(size_t i, int fallback) const { return i < params_.size() && params_[i] > 0 ? params_[i] : fallback; }
|
||||
|
||||
void Terminal::moveTo(int row, int col) {
|
||||
row_ = std::clamp(row, 0, rows_ - 1);
|
||||
col_ = std::clamp(col, 0, cols_ - 1);
|
||||
wrapPending_ = false;
|
||||
}
|
||||
|
||||
void Terminal::eraseCells(int row, int from, int to) {
|
||||
Cell b = blank();
|
||||
for (int c = std::max(0, from); c <= std::min(cols_ - 1, to); c++) grid()[static_cast<size_t>(row * cols_ + c)] = b;
|
||||
}
|
||||
|
||||
void Terminal::scrollUp(int top, int bottom, int n, bool toHistory) {
|
||||
n = std::min(n, bottom - top + 1);
|
||||
auto& g = grid();
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (toHistory && !alt_ && top == 0 && historyMax_ > 0) { // off the top of the real screen: kept, as text
|
||||
history_.push_back(rowText(0));
|
||||
if (static_cast<int>(history_.size()) > historyMax_) history_.pop_front();
|
||||
}
|
||||
std::move(g.begin() + (top + 1) * cols_, g.begin() + (bottom + 1) * cols_, g.begin() + top * cols_);
|
||||
eraseCells(bottom, 0, cols_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::scrollDown(int top, int bottom, int n) {
|
||||
n = std::min(n, bottom - top + 1);
|
||||
auto& g = grid();
|
||||
for (int i = 0; i < n; i++) {
|
||||
std::move_backward(g.begin() + top * cols_, g.begin() + bottom * cols_, g.begin() + (bottom + 1) * cols_);
|
||||
eraseCells(top, 0, cols_ - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::lineFeed() {
|
||||
wrapPending_ = false;
|
||||
if (row_ == bottom_) scrollUp(top_, bottom_, 1);
|
||||
else if (row_ < rows_ - 1) row_++;
|
||||
}
|
||||
|
||||
void Terminal::reverseIndex() {
|
||||
wrapPending_ = false;
|
||||
if (row_ == top_) scrollDown(top_, bottom_, 1);
|
||||
else if (row_ > 0) row_--;
|
||||
}
|
||||
|
||||
void Terminal::put(uint32_t cp) {
|
||||
uint8_t ch = lineDrawing_ && cp < 0x80 ? lineGlyph(static_cast<uint8_t>(cp)) : glyphFor(cp);
|
||||
if (wrapPending_) {
|
||||
col_ = 0;
|
||||
lineFeed();
|
||||
}
|
||||
uint8_t fg = static_cast<uint8_t>(bold_ && fg_ < 8 ? fg_ | 8 : fg_), bg = bg_;
|
||||
if (inverse_) std::swap(fg, bg);
|
||||
Cell& c = grid()[static_cast<size_t>(row_ * cols_ + col_)];
|
||||
c.ch = ch;
|
||||
c.attr = static_cast<uint8_t>((bg << 4) | (fg & 15));
|
||||
if (col_ == cols_ - 1) wrapPending_ = autoWrap_;
|
||||
else col_++;
|
||||
}
|
||||
|
||||
void Terminal::control(uint8_t c) {
|
||||
switch (c) {
|
||||
case 0x07: bell_ = true; break;
|
||||
case 0x08:
|
||||
if (col_ > 0) col_--;
|
||||
wrapPending_ = false;
|
||||
break;
|
||||
case 0x09: moveTo(row_, std::min(cols_ - 1, (col_ / 8 + 1) * 8)); break;
|
||||
case 0x0A: case 0x0B: case 0x0C: lineFeed(); break;
|
||||
case 0x0D:
|
||||
col_ = 0;
|
||||
wrapPending_ = false;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::reset() {
|
||||
alt_ = false;
|
||||
Cell b;
|
||||
std::fill(mainGrid_.begin(), mainGrid_.end(), b);
|
||||
std::fill(altGrid_.begin(), altGrid_.end(), b);
|
||||
row_ = col_ = top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
fg_ = 7;
|
||||
bg_ = 0;
|
||||
bold_ = inverse_ = wrapPending_ = appCursor_ = lineDrawing_ = false;
|
||||
autoWrap_ = cursorShown_ = true;
|
||||
}
|
||||
|
||||
void Terminal::escape(uint8_t c) {
|
||||
state_ = State::Ground;
|
||||
switch (c) {
|
||||
case '[':
|
||||
state_ = State::Csi;
|
||||
params_.clear();
|
||||
paramStarted_ = private_ = false;
|
||||
break;
|
||||
case ']': case 'P': case '^': case '_': state_ = State::Osc; break; // a title, or something this doesn't read: skipped to its end
|
||||
case '(': case ')': case '*': case '+': state_ = State::Charset; break;
|
||||
case '7':
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
savedAttr_ = static_cast<uint8_t>((bg_ << 4) | fg_);
|
||||
break;
|
||||
case '8':
|
||||
moveTo(savedRow_, savedCol_);
|
||||
fg_ = savedAttr_ & 15;
|
||||
bg_ = savedAttr_ >> 4;
|
||||
break;
|
||||
case 'D': lineFeed(); break;
|
||||
case 'E':
|
||||
col_ = 0;
|
||||
lineFeed();
|
||||
break;
|
||||
case 'M': reverseIndex(); break;
|
||||
case 'c': reset(); break;
|
||||
default: break; // = and >, the keypad's modes, among others
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::sgr() {
|
||||
if (params_.empty()) params_.push_back(0);
|
||||
for (size_t i = 0; i < params_.size(); i++) {
|
||||
int p = params_[i];
|
||||
if (p == 0) {
|
||||
fg_ = 7;
|
||||
bg_ = 0;
|
||||
bold_ = inverse_ = false;
|
||||
} else if (p == 1) bold_ = true;
|
||||
else if (p == 7) inverse_ = true;
|
||||
else if (p == 22) bold_ = false;
|
||||
else if (p == 27) inverse_ = false;
|
||||
else if (p >= 30 && p <= 37) fg_ = static_cast<uint8_t>(p - 30);
|
||||
else if (p == 39) fg_ = 7;
|
||||
else if (p >= 40 && p <= 47) bg_ = static_cast<uint8_t>(p - 40);
|
||||
else if (p == 49) bg_ = 0;
|
||||
else if (p >= 90 && p <= 97) fg_ = static_cast<uint8_t>(p - 90 + 8);
|
||||
else if (p >= 100 && p <= 107) bg_ = static_cast<uint8_t>(p - 100 + 8);
|
||||
else if ((p == 38 || p == 48) && i + 1 < params_.size()) {
|
||||
int colour = -1;
|
||||
if (params_[i + 1] == 5 && i + 2 < params_.size()) {
|
||||
colour = from256(params_[i + 2] & 255);
|
||||
i += 2;
|
||||
} else if (params_[i + 1] == 2 && i + 4 < params_.size()) {
|
||||
colour = nearest16(params_[i + 2] & 255, params_[i + 3] & 255, params_[i + 4] & 255);
|
||||
i += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
(p == 38 ? fg_ : bg_) = static_cast<uint8_t>(colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::mode(bool on) {
|
||||
for (int p : params_) {
|
||||
if (!private_) continue;
|
||||
switch (p) {
|
||||
case 1: appCursor_ = on; break;
|
||||
case 7: autoWrap_ = on; break;
|
||||
case 25: cursorShown_ = on; break;
|
||||
case 47: case 1047: case 1049:
|
||||
if (on == alt_) break;
|
||||
if (on && p == 1049) {
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
}
|
||||
alt_ = on;
|
||||
if (on) std::fill(altGrid_.begin(), altGrid_.end(), Cell());
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
if (!on && p == 1049) moveTo(savedRow_, savedCol_);
|
||||
else if (on) moveTo(0, 0);
|
||||
break;
|
||||
default: break; // the mouse, bracketed paste and the rest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::csi(uint8_t final) {
|
||||
int n = param(0, 1);
|
||||
switch (final) {
|
||||
case 'A': moveTo(std::max(row_ - n, row_ >= top_ ? top_ : 0), col_); break;
|
||||
case 'B': case 'e': moveTo(std::min(row_ + n, row_ <= bottom_ ? bottom_ : rows_ - 1), col_); break;
|
||||
case 'C': case 'a': moveTo(row_, col_ + n); break;
|
||||
case 'D': moveTo(row_, col_ - n); break;
|
||||
case 'E': moveTo(row_ + n, 0); break;
|
||||
case 'F': moveTo(row_ - n, 0); break;
|
||||
case 'G': case '`': moveTo(row_, n - 1); break;
|
||||
case 'd': moveTo(n - 1, col_); break;
|
||||
case 'H': case 'f': moveTo(param(0, 1) - 1, param(1, 1) - 1); break;
|
||||
case 'J': {
|
||||
int what = params_.empty() ? 0 : params_[0];
|
||||
if (what == 0) {
|
||||
eraseCells(row_, col_, cols_ - 1);
|
||||
for (int r = row_ + 1; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
} else if (what == 1) {
|
||||
for (int r = 0; r < row_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
eraseCells(row_, 0, col_);
|
||||
} else {
|
||||
for (int r = 0; r < rows_; r++) eraseCells(r, 0, cols_ - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'K': {
|
||||
int what = params_.empty() ? 0 : params_[0];
|
||||
eraseCells(row_, what == 0 ? col_ : 0, what == 1 ? col_ : cols_ - 1);
|
||||
break;
|
||||
}
|
||||
case 'L':
|
||||
if (row_ >= top_ && row_ <= bottom_) scrollDown(row_, bottom_, n);
|
||||
break;
|
||||
case 'M':
|
||||
if (row_ >= top_ && row_ <= bottom_) scrollUp(row_, bottom_, n, false); // deleted, not scrolled away: not history
|
||||
break;
|
||||
case 'P': {
|
||||
n = std::min(n, cols_ - col_);
|
||||
auto row = grid().begin() + row_ * cols_;
|
||||
std::move(row + col_ + n, row + cols_, row + col_);
|
||||
eraseCells(row_, cols_ - n, cols_ - 1);
|
||||
break;
|
||||
}
|
||||
case '@': {
|
||||
n = std::min(n, cols_ - col_);
|
||||
auto row = grid().begin() + row_ * cols_;
|
||||
std::move_backward(row + col_, row + cols_ - n, row + cols_);
|
||||
eraseCells(row_, col_, col_ + n - 1);
|
||||
break;
|
||||
}
|
||||
case 'X': eraseCells(row_, col_, col_ + n - 1); break;
|
||||
case 'S': scrollUp(top_, bottom_, n); break;
|
||||
case 'T': scrollDown(top_, bottom_, n); break;
|
||||
case 'm': sgr(); break;
|
||||
case 'r': {
|
||||
int top = param(0, 1) - 1, bottom = param(1, rows_) - 1;
|
||||
if (top < bottom && bottom < rows_) {
|
||||
top_ = top;
|
||||
bottom_ = bottom;
|
||||
} else {
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
}
|
||||
moveTo(0, 0);
|
||||
break;
|
||||
}
|
||||
case 's':
|
||||
savedRow_ = row_;
|
||||
savedCol_ = col_;
|
||||
break;
|
||||
case 'u': moveTo(savedRow_, savedCol_); break;
|
||||
case 'h': mode(true); break;
|
||||
case 'l': mode(false); break;
|
||||
case 'n':
|
||||
if (!reply) break;
|
||||
if (param(0, 0) == 6) reply("\x1b[" + std::to_string(row_ + 1) + ";" + std::to_string(col_ + 1) + "R");
|
||||
else if (param(0, 0) == 5) reply("\x1b[0n");
|
||||
break;
|
||||
case 'c':
|
||||
if (reply && !private_) reply("\x1b[?6c"); // "a VT102"
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void Terminal::feed(const uint8_t* data, size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
uint8_t c = data[i];
|
||||
if (state_ == State::Osc || state_ == State::OscEsc) { // skipped: to a bell, or to ESC backslash
|
||||
if (c == 0x07 || (state_ == State::OscEsc && c == '\\')) state_ = State::Ground;
|
||||
else state_ = c == 0x1B ? State::OscEsc : State::Osc;
|
||||
continue;
|
||||
}
|
||||
if (c == 0x1B) {
|
||||
state_ = State::Esc;
|
||||
utf8Left_ = 0;
|
||||
continue;
|
||||
}
|
||||
if (c < 0x20) { // these act wherever they come, in the middle of a sequence too
|
||||
if (c == 0x0E) lineDrawing_ = true;
|
||||
else if (c == 0x0F) lineDrawing_ = false;
|
||||
else control(c);
|
||||
continue;
|
||||
}
|
||||
switch (state_) {
|
||||
case State::Esc: escape(c); break;
|
||||
case State::Charset:
|
||||
lineDrawing_ = c == '0';
|
||||
state_ = State::Ground;
|
||||
break;
|
||||
case State::Csi:
|
||||
if (c >= '0' && c <= '9') {
|
||||
if (!paramStarted_) {
|
||||
if (params_.size() < 16) params_.push_back(0);
|
||||
paramStarted_ = true;
|
||||
}
|
||||
if (!params_.empty() && params_.back() < 10000) params_.back() = params_.back() * 10 + (c - '0');
|
||||
} else if (c == ';' || c == ':') {
|
||||
if (!paramStarted_ && params_.size() < 16) params_.push_back(0);
|
||||
paramStarted_ = false;
|
||||
} else if (c == '?' || c == '>' || c == '=' || c == '<') {
|
||||
private_ = true;
|
||||
} else if (c >= 0x40 && c <= 0x7E) {
|
||||
state_ = State::Ground;
|
||||
csi(c);
|
||||
} // anything else is an in-between byte: passed over
|
||||
break;
|
||||
default:
|
||||
if (c == 0x7F) break;
|
||||
if (c < 0x80) {
|
||||
utf8Left_ = 0;
|
||||
put(c);
|
||||
} else if (c >= 0xC0) { // the first byte of a longer character
|
||||
utf8Left_ = c >= 0xF0 ? 3 : c >= 0xE0 ? 2 : 1;
|
||||
utf8_ = c & (c >= 0xF0 ? 0x07 : c >= 0xE0 ? 0x0F : 0x1F);
|
||||
} else if (utf8Left_ > 0) {
|
||||
utf8_ = (utf8_ << 6) | (c & 0x3F);
|
||||
if (--utf8Left_ == 0) put(utf8_);
|
||||
} else {
|
||||
put('?'); // a byte that belongs to nothing
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
revision_++;
|
||||
}
|
||||
|
||||
void Terminal::resize(int cols, int rows) {
|
||||
cols = std::max(2, cols);
|
||||
rows = std::max(2, rows);
|
||||
if (cols == cols_ && rows == rows_) return;
|
||||
// The cursor's line stays on screen: what is above it goes to the history if it has to.
|
||||
int shift = alt_ ? 0 : std::max(0, row_ - (rows - 1));
|
||||
if (shift) {
|
||||
int oldTop = top_, oldBottom = bottom_;
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
scrollUp(0, rows_ - 1, shift);
|
||||
top_ = oldTop;
|
||||
bottom_ = oldBottom;
|
||||
}
|
||||
auto copy = [&](std::vector<Cell>& g) {
|
||||
std::vector<Cell> fresh(static_cast<size_t>(cols * rows));
|
||||
for (int r = 0; r < std::min(rows, rows_); r++)
|
||||
for (int c = 0; c < std::min(cols, cols_); c++) fresh[static_cast<size_t>(r * cols + c)] = g[static_cast<size_t>(r * cols_ + c)];
|
||||
g.swap(fresh);
|
||||
};
|
||||
copy(mainGrid_);
|
||||
copy(altGrid_);
|
||||
cols_ = cols;
|
||||
rows_ = rows;
|
||||
top_ = 0;
|
||||
bottom_ = rows_ - 1;
|
||||
moveTo(row_ - shift, col_);
|
||||
savedRow_ = std::min(savedRow_, rows_ - 1);
|
||||
savedCol_ = std::min(savedCol_, cols_ - 1);
|
||||
revision_++;
|
||||
}
|
||||
|
||||
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys) {
|
||||
std::string out;
|
||||
auto arrow = [&](char letter) { return std::string(appCursorKeys ? "\x1bO" : "\x1b[") + letter; };
|
||||
switch (key) {
|
||||
case TermKey::Up: out = arrow('A'); break;
|
||||
case TermKey::Down: out = arrow('B'); break;
|
||||
case TermKey::Right: out = arrow('C'); break;
|
||||
case TermKey::Left: out = arrow('D'); break;
|
||||
case TermKey::Enter: out = "\r"; break;
|
||||
case TermKey::Backspace: out = "\x7f"; break;
|
||||
case TermKey::Tab: out = "\t"; break;
|
||||
case TermKey::Escape: out = "\x1b"; break;
|
||||
case TermKey::PageUp: out = "\x1b[5~"; break;
|
||||
case TermKey::PageDown: out = "\x1b[6~"; break;
|
||||
case TermKey::Char:
|
||||
if (ctrl) {
|
||||
uint32_t c = ch >= 'a' && ch <= 'z' ? ch - 32 : ch;
|
||||
if (c >= '@' && c <= '_') out = std::string(1, static_cast<char>(c - '@'));
|
||||
else if (c == ' ' || c == '2') out = std::string(1, '\0');
|
||||
else if (c == '?' || c == '8') out = "\x7f";
|
||||
else if (c == '3') out = "\x1b";
|
||||
else if (c == '4') out = "\x1c";
|
||||
else if (c == '5') out = "\x1d";
|
||||
else if (c == '6') out = "\x1e";
|
||||
else if (c == '7' || c == '/') out = "\x1f";
|
||||
break;
|
||||
}
|
||||
if (ch < 0x80) out = std::string(1, static_cast<char>(ch));
|
||||
else if (ch < 0x800) out = {static_cast<char>(0xC0 | (ch >> 6)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
else if (ch < 0x10000) out = {static_cast<char>(0xE0 | (ch >> 12)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)), static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
else out = {static_cast<char>(0xF0 | (ch >> 18)), static_cast<char>(0x80 | ((ch >> 12) & 0x3F)), static_cast<char>(0x80 | ((ch >> 6) & 0x3F)),
|
||||
static_cast<char>(0x80 | (ch & 0x3F))};
|
||||
break;
|
||||
}
|
||||
if (alt && !out.empty() && key != TermKey::Escape) out.insert(0, 1, '\x1b');
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// A terminal's screen (issue #2, N1 Q256): what a remote program's output makes of a grid of
|
||||
// characters. It understands what a shell, `less`, `top`, `nano` and plain `vim` send: the cursor,
|
||||
// erasing, sixteen colours, scroll regions, the alternate screen. No mouse. Characters are kept
|
||||
// as the screen's font has them (Latin-1); what it lacks becomes `?`, and box-drawing lines
|
||||
// become + - |.
|
||||
namespace roro::term {
|
||||
|
||||
struct Cell {
|
||||
uint8_t ch = ' ';
|
||||
uint8_t attr = 0x07; // low four bits: the colour of the character, high four: of what is behind it
|
||||
};
|
||||
|
||||
class Terminal {
|
||||
public:
|
||||
Terminal(int cols, int rows, int historyLines);
|
||||
|
||||
void feed(const uint8_t* data, size_t len);
|
||||
// A new size: what is on screen stays where it is, from the top left; if the cursor would fall
|
||||
// off the bottom, the top lines go to the history.
|
||||
void resize(int cols, int rows);
|
||||
// What the program asked to be told (where the cursor is, what kind of terminal this is).
|
||||
std::function<void(const std::string&)> reply;
|
||||
|
||||
int cols() const { return cols_; }
|
||||
int rows() const { return rows_; }
|
||||
const Cell& cell(int row, int col) const { return grid()[static_cast<size_t>(row * cols_ + col)]; }
|
||||
int cursorRow() const { return row_; }
|
||||
int cursorCol() const { return col_; }
|
||||
bool cursorVisible() const { return cursorShown_; }
|
||||
bool appCursorKeys() const { return appCursor_; } // the arrows are sent another way (vim, less)
|
||||
bool altScreen() const { return alt_; }
|
||||
uint32_t revision() const { return revision_; } // changes whenever the screen may have
|
||||
bool takeBell();
|
||||
|
||||
// Lines that scrolled off the top of the main screen, oldest first; their text only.
|
||||
int historyCount() const { return static_cast<int>(history_.size()); }
|
||||
const std::string& historyLine(int i) const { return history_[static_cast<size_t>(i)]; }
|
||||
|
||||
std::string rowText(int row) const; // without trailing spaces
|
||||
|
||||
private:
|
||||
enum class State { Ground, Esc, Csi, Osc, OscEsc, Charset };
|
||||
|
||||
std::vector<Cell>& grid() { return alt_ ? altGrid_ : mainGrid_; }
|
||||
const std::vector<Cell>& grid() const { return alt_ ? altGrid_ : mainGrid_; }
|
||||
Cell blank() const;
|
||||
void put(uint32_t codePoint);
|
||||
void control(uint8_t c);
|
||||
void escape(uint8_t c);
|
||||
void csi(uint8_t final);
|
||||
void sgr();
|
||||
void mode(bool on);
|
||||
void lineFeed();
|
||||
void reverseIndex();
|
||||
void scrollUp(int top, int bottom, int n, bool toHistory = true);
|
||||
void scrollDown(int top, int bottom, int n);
|
||||
void eraseCells(int row, int from, int to);
|
||||
void moveTo(int row, int col);
|
||||
void reset();
|
||||
int param(size_t i, int fallback) const;
|
||||
|
||||
int cols_, rows_, historyMax_;
|
||||
std::vector<Cell> mainGrid_, altGrid_;
|
||||
std::deque<std::string> history_;
|
||||
bool alt_ = false;
|
||||
int row_ = 0, col_ = 0, top_ = 0, bottom_ = 0;
|
||||
int savedRow_ = 0, savedCol_ = 0;
|
||||
uint8_t savedAttr_ = 0x07;
|
||||
bool wrapPending_ = false, autoWrap_ = true, cursorShown_ = true, appCursor_ = false, lineDrawing_ = false, bell_ = false;
|
||||
uint8_t fg_ = 7, bg_ = 0;
|
||||
bool bold_ = false, inverse_ = false;
|
||||
State state_ = State::Ground;
|
||||
std::vector<int> params_;
|
||||
bool paramStarted_ = false, private_ = false;
|
||||
uint32_t utf8_ = 0;
|
||||
int utf8Left_ = 0;
|
||||
uint32_t revision_ = 0;
|
||||
};
|
||||
|
||||
// What a key sends to the remote program.
|
||||
enum class TermKey { Char, Up, Down, Left, Right, Enter, Backspace, Tab, Escape, PageUp, PageDown };
|
||||
std::string encodeKey(TermKey key, uint32_t ch, bool ctrl, bool alt, bool appCursorKeys);
|
||||
|
||||
// One of the terminal's sixteen colours as red, green and blue.
|
||||
void colourRgb(int index, uint8_t& r, uint8_t& g, uint8_t& b);
|
||||
|
||||
} // namespace roro::term
|
||||
@@ -9,6 +9,9 @@ extra_scripts = pre:scripts/version.py
|
||||
test_framework = unity
|
||||
|
||||
[env:cardputer-adv]
|
||||
extra_scripts =
|
||||
${env.extra_scripts}
|
||||
pre:scripts/libssh_filter.py
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip
|
||||
board = m5stack-stamps3
|
||||
framework = arduino
|
||||
@@ -17,9 +20,12 @@ 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
|
||||
ewpa/LibSSH-ESP32 @ 5.10.0
|
||||
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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# libssh ships its own copy of curve25519 (src/external/curve25519_ref.c), whose two functions,
|
||||
# crypto_scalarmult and crypto_scalarmult_base, have the names libsodium's have: and libsodium is
|
||||
# already in the firmware, for WireGuard. Two definitions don't link. libssh's copy is left out
|
||||
# of the build and it uses libsodium's, which is the same function (issue #2, docs/milestones/N1.md).
|
||||
#
|
||||
# A `pre:` script: the libraries are built by the platform's own script, which runs before any `post:` one.
|
||||
Import("env") # noqa: F821 (provided by PlatformIO)
|
||||
|
||||
env.AddBuildMiddleware(lambda env, node: None, "*LibSSH-ESP32*curve25519_ref.c") # noqa: F821
|
||||
@@ -39,6 +39,11 @@ install <path.ota> Update from SD
|
||||
update check | update list | update status | update install <tag> the project's releases on Gitea
|
||||
sd card | sd list | cat <path> | log <text> | 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
|
||||
ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time
|
||||
tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one
|
||||
ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected
|
||||
ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here
|
||||
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 +114,11 @@ 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 |
|
||||
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
|
||||
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
|
||||
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
|
||||
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
|
||||
| `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 <value>` / `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 |
|
||||
|
||||
@@ -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: <kbd>Fn</kbd> + <kbd>p</kbd> saves a screenshot to the card ([how-to](/howto/screenshot/)), and <kbd>w</kbd> 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
+++
|
||||
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**. The network troubleshooting commands (issue #90) shipped in two parts: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` as **v0.20.0**; `tls`, `ntp` and `netstat` as **v0.21.0**. The SSH client (issue #2) is built and waits for its release.
|
||||
|
||||
**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 <seconds>`** 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 put on the card by the maintainer 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.
|
||||
|
||||
## Network troubleshooting commands (issue #90)
|
||||
|
||||
With a tunnel, fixed addresses and a file server on the device, "is it the network or is it me" needed another machine to answer.
|
||||
|
||||
### Decisions (2026-10-08; built on the issue's list, without a round of questions)
|
||||
|
||||
- **The familiar names:** `ping`, `nslookup`, `traceroute`, `ifconfig`, `arp`. `port <host> <port>` for "is that TCP port open", which has no single familiar name.
|
||||
- **In this version:** those six. **Not yet:** `tls` (why a certificate fails), `ntp` (the clock's offset), `netstat` (what listens). The issue stays open for them.
|
||||
- **Commands only,** in the Shell and over both consoles; no page in an App.
|
||||
- **One line an answer, short:** a Shell line is 38 characters.
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/net/src/net_probe.h`** (host-tested, 5 tests): what was typed; the ICMP echo request and what answers it, a router's "time exceeded" included; the DNS query and its answer, with the pointers names are shortened by.
|
||||
- **`NetTools`** (`src/services/net_tools`): `ping`, `nslookup`, `port` and `traceroute` each run on a task of their own, made for the command and gone after it, printing to the console that asked (the Shell shows only its own replies). One at a time; `cancel` stops it within a fifth of a second.
|
||||
- **`ping`** and **`traceroute`** share a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.
|
||||
- **`nslookup`** asks one server itself, over UDP, and so can say which server answered and how long it took, which the system's resolver doesn't; and it can ask a server that isn't the configured one.
|
||||
- **`port`** is a connection attempt that is not waited for: open, refused, or five seconds of nothing.
|
||||
- **`ifconfig`** and **`arp`** read lwIP's own lists, with its lock held.
|
||||
- **Cost:** 12 KB of flash. A 6 KB task while a command runs (2.6 KB of it never used), nothing otherwise.
|
||||
|
||||
### Checks on the device (2026-10-08, with the VPN up and everything routed through it)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ifconfig` | `vpn 10.9.0.2/32 mtu 1420, up, default route`; `wifi ... gw ... mtu 1500, up`; the DNS server |
|
||||
| `arp` | The gateway and one other machine |
|
||||
| `ping` of a neighbour, of a name | 4 of 4 in 3 to 4 ms; 3 of 3 in about 50 ms |
|
||||
| `ping 9.9.9.9 2 1392`, then `1393` | Both back; neither back: the tunnel carries 1420 bytes exactly |
|
||||
| `nslookup` | The address, the server and the time; an alias followed; with another server; "there is no nope.invalid" |
|
||||
| `port` | `open, 52 ms`; `refused`; "no answer in 5 s"; "doesn't resolve" |
|
||||
| `traceroute 9.9.9.9` | Nine hops, the tunnel's server first, "arrived" |
|
||||
| A second command while a ping runs | "another one is running: `cancel` stops it" |
|
||||
| `cancel` | "stopped", with the count so far |
|
||||
| In the Shell | Tab completes them; the lines appear there and only there |
|
||||
|
||||
**Not checked:** without the VPN (every check went through the tunnel, or to the local network); a network that drops ICMP; the commands in Safe Mode, where they are not offered.
|
||||
|
||||
**Found on the way:** a refused connection is reported by lwIP as "reset", not "refused"; the first version called it "no route". And the header for the tested half was first given the same name as the service's, which makes a file include itself: the same mistake as an hour before, in the same way.
|
||||
|
||||
### The rest of the list: `tls`, `ntp`, `netstat` (2026-10-08)
|
||||
|
||||
- **`tls <host> [port]`** makes a handshake that checks nothing, so that a bad certificate can be looked at, and then checks it itself: against this device's roots (`ca_roots.h`, the ones the Update Service trusts) and the name asked for. It says who the certificate is for, who signed it, from when to when with the days left, the verdict with its reasons, and the SHA-256 that a Gemini pin is. It runs on a 12 KB task and isn't tried with less than 70 KB free: a handshake peaks at about 52 KB.
|
||||
- **`ntp [server]`** sends one SNTP request and compares the answer with the device's clock, allowing for half the round trip. With no server it asks the first one in Settings.
|
||||
- **`netstat`** reads lwIP's own lists: what listens, labelled where the firmware knows what it is, what is connected, and the UDP ports in use.
|
||||
- Host tests: the NTP packet and the year 2036, the offset in words, a certificate's name (an old string type that mbedTLS prints as hex included), days between dates. 7 tests in `test/test_net_probe` in all.
|
||||
|
||||
| Check on the device | Result |
|
||||
|---|---|
|
||||
| `tls git.twis.la` | 709 ms; for git.twis.la, 67 days left, "this device trusts it", the SHA-256 |
|
||||
| `tls geminiprotocol.net 1965` | "NOT trusted here: not signed by a root this device has": a capsule signs its own |
|
||||
| `tls expired.badssl.com` | "EXPIRED 4197 days ago" |
|
||||
| `tls wrong.host.badssl.com` | "NOT trusted here: not for that name" |
|
||||
| `tls` to a port that isn't TLS | "no handshake ... An invalid SSL record was received" |
|
||||
| `ntp` | The server, its stratum, 50 ms away; "this clock is right, to 0.1 s" |
|
||||
| `netstat` | The update port and the Debug Console listening, the console's own connection, the UDP ports |
|
||||
| Memory during a `tls` | 44.5 KB free at the lowest, from 104 KB |
|
||||
|
||||
**Not checked:** `tls` with IRC connected (it should refuse for lack of memory); `ntp` against a clock that is wrong; `netstat` while sharing.
|
||||
|
||||
## The SSH client (issue #2)
|
||||
|
||||
A terminal on another machine: one session to a shell, from the SSH App.
|
||||
|
||||
### Measured before deciding (2026-10-08)
|
||||
|
||||
`ewpa/LibSSH-ESP32` 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
|
||||
|
||||
| | Measured in the trial | As built |
|
||||
|---|---|---|
|
||||
| Flash | 120 KB | **292 KB** |
|
||||
| Static RAM | 1.2 KB | |
|
||||
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
|
||||
| Free heap with a session open | | 49 KB of 99 KB: it costs about 50 KB, the stack included |
|
||||
| Lowest free heap during a login | | 30 KB |
|
||||
| Key exchange (curve25519, ed25519 host key) | 227 ms | |
|
||||
|
||||
- **The trial undercounted the flash.** It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point: `ge25519.c.o` alone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot.
|
||||
- **libssh carries its own curve25519** (`src/external/curve25519_ref.c`) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (`scripts/libssh_filter.py`) leaves libssh's copy out, and it uses libsodium's. It has to be a `pre:` script: libraries are built before any `post:` one runs.
|
||||
- A session and a TLS connection don't fit together: IRC holds 40 KB.
|
||||
|
||||
### Decisions (design round 2026-10-08)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q254 | **LibSSH-ESP32 5.10.0**, with its duplicate curve file left out of the build. |
|
||||
| Q255 | **A password, typed each time and never stored**, or **a key the device makes for itself** (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to `/ssh/id_ed25519.pub` and printed by `ssh status`. Keys made elsewhere aren't imported. |
|
||||
| Q256 | **A terminal good enough for a shell, `less`, `top`, `nano` and `vim`:** cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. `TERM=xterm`. No mouse. |
|
||||
| Q257 | **Five text sizes, changed with Ctrl and + or -** (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
|
||||
| Q258 | **100 lines of scrollback**, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
|
||||
| Q259 | **Keys:** Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
|
||||
| Q260 | **The session outlives the App's time in front.** `SSH` in the Status Bar while one is open. |
|
||||
| Q261 | **Not started under 75 KB free**, with the reason in words. |
|
||||
| Q262 | **Up to eight hosts remembered**, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
|
||||
| Q263 | **Trust on first use,** on the SHA-256 fingerprint; sixteen servers remembered. **A changed key is a warning**, with Cancel selected. |
|
||||
| Q264 | **UTF-8 in, the fonts' Latin-1 out:** what they lack is `?`, box-drawing lines are `+ - \|`. |
|
||||
| Q265 | **`ssh user@host` in the Shell opens the App** and connects there. The password is never asked on a console. |
|
||||
| Q266 | **Not built:** port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/term`** (host-tested, 12 tests in `test/test_terminal`): `Terminal`, the screen a program's output makes, with its history and the replies a program asks for; `encodeKey`, what a key sends; `SshHosts` and `SshKnownHosts`, the two lists kept in the settings store as lines of text.
|
||||
- **`SshService`** (`src/services/ssh_service`): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.
|
||||
- **`SshApp`** (`src/apps/ssh_app`): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.
|
||||
- **Keys that aren't characters now say what was held with them** (`lib/input/src/key_mapper.cpp`): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console's `key` command: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini.
|
||||
- **IRC doesn't try to connect with less than 60 KB free** (`IrcService::kNeedFree`): see below.
|
||||
- Settings: `SshHosts`, `SshKnown`, `SshKey`, `SshPublic`, `SshFont`.
|
||||
|
||||
### Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `ssh tester@host:2222` from the Debug Console | The App opens; the fingerprint shown is the one `ssh-keygen -lf` prints on the server |
|
||||
| Trust it, a password | A shell; `stty size` says 15 48, `$TERM` is xterm |
|
||||
| `ls -la`, `top`, `vim` (insert, Esc, `:wq`) | Drawn right: `top`'s reverse-video header, `vim`'s alternate screen and what was there before coming back; the file is on the server |
|
||||
| Ctrl with + and - | `stty size` says 12 40, then 20 60; `top` redraws for it |
|
||||
| `seq 1 60`, Alt with up | The history, in grey, with how far back in the corner |
|
||||
| Fn+backtick, then the App again | The Launcher with `SSH` in the Status Bar; the session as it was |
|
||||
| `sleep 100`, Ctrl+C; Alt+backtick | Interrupted; `` echo `id -u` `` prints 1000 |
|
||||
| Ctrl+Alt+q; `exit` | "Disconnected"; "The session ended" |
|
||||
| This device's key, its public half in `authorized_keys` | "Accepted publickey" in the server's log; no password asked |
|
||||
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
|
||||
| A wrong password | "Wrong password", and asked again; Back gives up |
|
||||
| A port nothing listens on | "Nothing listens there: the connection was refused" |
|
||||
| `ssh nobody`, `ssh a@`, a port of 99999 | Refused, each with its reason |
|
||||
| Forgetting a host | Asked, then gone from the list |
|
||||
| `irc start` with a session open | "not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
|
||||
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
|
||||
| Stacks | `ssh` 6.8 KB free of 20 KB; `loopTask` 1.9 KB free, as before |
|
||||
|
||||
**Not checked:** the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. `nano`, `less`, `htop`, `tmux`. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
- **IRC, started with a session open, took the free heap down to 236 bytes.** A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
|
||||
- **The build script did nothing as a `post:` script:** the libraries were already built when it ran.
|
||||
- **A failed connection was first shown as an empty terminal** with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
|
||||
- **The trust question didn't fit its dialog:** the fingerprint is 50 characters. It is now split over two lines, under one line of words.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,312 @@
|
||||
+++
|
||||
title = '''Six releases behind'''
|
||||
description = '''roro9stack gets a WireGuard tunnel, and the tools to find out why a network doesn't work: ping, nslookup, traceroute, a TLS check and the rest. In between, I looked at the project's own website and found that the documentation had stopped keeping up six releases earlier, and nobody had noticed, me included.'''
|
||||
date = 2026-10-08T03:15:00+02:00
|
||||
|
||||
[extra]
|
||||
topics = '''ESP32-S3 · WireGuard · Documentation'''
|
||||
read_label = '''Read what was missing →'''
|
||||
uid = '''<b>ifconfig:</b> vpn 10.9.0.2/32 mtu 1420, up, default route'''
|
||||
dek = "Three more releases of [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a VPN (v0.19.0) and nine commands for troubleshooting a network from the device itself (v0.20.0 and v0.21.0). The tunnel crashed the device the first time it was started and then turned out to be the easy part. The hard part was noticing that the user guide described a firmware from the day before."
|
||||
byline = '''measured before deciding, for once; then promised more than the network stack could do'''
|
||||
|
||||
[extra.sign]
|
||||
label = "Releases shipped with the documentation behind"
|
||||
note = "v0.13.0 to v0.18.0. Each had its own page updated, and nothing around it."
|
||||
count = "6"
|
||||
tone = "red"
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The tunnel"
|
||||
role = "WireGuard, one peer, IPv4"
|
||||
text = "Costs under 2 KB of memory once it's up, which on this device is close to free. Stopped the device dead the first time it was asked to start."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "lwIP"
|
||||
role = "the network stack"
|
||||
text = "Has a lock, and in this firmware it checks that you hold it. Has no routing table, which I found out after promising one."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The test peer"
|
||||
role = "a WireGuard server in a container"
|
||||
text = "Could reach the device. The device couldn't reach it. So the server called the client, which WireGuard doesn't mind at all."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The home page"
|
||||
role = "of this site"
|
||||
text = "Said the Storage App opens text, hex, captures and tracks. It had been showing pictures for four releases and serving files to phones for one."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "ping"
|
||||
role = "and eight friends"
|
||||
text = "nslookup, port, traceroute, tls, ntp, ifconfig, arp, netstat. The first thing I did with them was measure my own tunnel, and learn something."
|
||||
+++
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **A WireGuard VPN** (**v0.19.0**): copy a client `.conf` to the card, import it in Settings, switch it on. One tunnel, to one server. It carries everything, or the tunnel's own subnet.
|
||||
- **It costs 63 KB of flash and under 2 KB of memory.** The library crashed the firmware on its first call; the fix was three lines of ours.
|
||||
- **I promised split tunnels by `AllowedIPs` and couldn't deliver:** the network stack routes by one subnet or by default, nothing finer.
|
||||
- **The documentation was six releases behind.** Every feature had its own page. The home page, Settings, the Status Bar, the how-tos, the glossary and the README's first paragraph had none of it.
|
||||
- **Nine network commands** in the Shell (**v0.20.0**, **v0.21.0**): `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp`, `netstat`.
|
||||
- A ping of 1392 bytes crosses my tunnel and one of 1393 doesn't. I now know my tunnel's MTU to the byte, from a device with a 240-pixel screen.
|
||||
- 546 host tests, 13 more than last time.
|
||||
|
||||
## The cast
|
||||
|
||||
{{ cast() }}
|
||||
|
||||
## Measured first, for once
|
||||
|
||||
The issue for the VPN had a line I'd written days ago and am glad of: *measure both libraries first.* So before any design, a trial firmware with the maintained library in it, a throwaway WireGuard server in a container, and a real tunnel.
|
||||
|
||||
{% table() %}
|
||||
| | Cost |
|
||||
|---|---|
|
||||
| Flash, the library | 43 KB |
|
||||
| Flash, with the service, the Settings page and the commands | 63 KB |
|
||||
| Static RAM | 1.2 KB |
|
||||
| Heap with the tunnel up | 1.8 KB |
|
||||
{% end %}
|
||||
|
||||
On a device where a TLS connection takes 52 KB, a VPN for 1.8 is a gift. That number alone decided most of the design round: no memory floors, no "close IRC first", nothing to ration.
|
||||
|
||||
Getting to that number took three surprises.
|
||||
|
||||
### It stopped on the first call
|
||||
|
||||
{% code(caption="The first `wg up`. The crash report named the line.") %}
|
||||
```
|
||||
assert failed: netif_add /IDF/components/lwip/lwip/src/core/netif.c:297
|
||||
(Required to lock TCPIP core functionality!)
|
||||
```
|
||||
{% end %}
|
||||
|
||||
The library talks to lwIP, the network stack, through its low-level functions, and takes no lock while it does. Most builds don't mind. This firmware's framework is built with the check switched on, and so the first `netif_add` was the last thing the device did.
|
||||
|
||||
The fix isn't in the library. Every call into it is made with the lock held, on our side: a three-line guard object. The library is used exactly as published.
|
||||
|
||||
### The server had to call the client
|
||||
|
||||
My device was on a guest Wi-Fi. The test server was on another network, and the guest network doesn't let its devices open connections inward. No handshake. For a while it looked like the library didn't work.
|
||||
|
||||
It worked fine. WireGuard doesn't have clients and servers, only peers, and either can call the other. I gave the device a fixed port to listen on, told the *server* where the device was, and the server started the handshake. Twenty-five pings of twenty-five, through a tunnel set up backwards.
|
||||
|
||||
(Later, on a network where the device could reach out, the normal direction worked first time. And then against my real server, which is the test that counts.)
|
||||
|
||||
### "What AllowedIPs say"
|
||||
|
||||
In the design round I'd agreed to this: *what goes through the tunnel is what the file's `AllowedIPs` line says.* Home subnets through the tunnel, the rest out over Wi-Fi. That's what every WireGuard client does.
|
||||
|
||||
Then I read how lwIP decides where a packet goes. It has two rules: the packet is for an interface's own subnet, or it goes to the default interface. There is no routing table to add "192.168.1.0/24 via the tunnel" to.
|
||||
|
||||
So the firmware does one of two things, and says which when you import a file:
|
||||
|
||||
{% table() %}
|
||||
| The file says | What happens |
|
||||
|---|---|
|
||||
| `AllowedIPs = 0.0.0.0/0` | Everything goes through the tunnel |
|
||||
| Anything else | The one subnet the device's tunnel address is in |
|
||||
{% end %}
|
||||
|
||||
A home network *behind* the server needs the first kind. An import with more ranges than that says so: "through it 10.9.0.0/24, not 1 other range". I'd rather the screen admit a limit than the documentation.
|
||||
|
||||
It also changed an answer I'd given about a kill switch. I'd said there wouldn't be one. With everything routed into the tunnel, there is: while the server is silent the default route still points into a tunnel that has nowhere to send, and nothing leaves. Not by decision. By construction.
|
||||
|
||||
{{ figure(src="vpn.png", alt="The Cardputer's Settings, VPN page at 2x: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then in blue It is up, heard 66 s ago, and in grey the server's address and This device 10.9.0.2, through it everything. The Status Bar shows VPN in blue.", width=480, height=270, caption="Settings → VPN, against the test server. No key appears on this page, or anywhere else: the private key goes in with the file and is never shown again.") }}
|
||||
|
||||
## Taking it down took my connection with it
|
||||
|
||||
One more, because it's the kind of bug that only shows when you test the way you work.
|
||||
|
||||
The tunnel puts its own DNS servers in while it's up, and has to give the old ones back when it stops. The Wi-Fi settings already had a way to get DHCP's servers back: ask for a new lease. So I called that.
|
||||
|
||||
A new lease reconnects Wi-Fi. Reconnecting Wi-Fi drops every connection, including the Debug Console session I had just typed `vpn down` into. The command worked and I never saw it say so.
|
||||
|
||||
The tunnel now remembers what was there and puts it back. It also notices when a DHCP renewal replaces its servers mid-flight, puts its own back in, and keeps the renewed ones for later.
|
||||
|
||||
## Six releases behind
|
||||
|
||||
With the tunnel working against my real server, I went to the website to see how it read. And then I went through the rest of the site, and it got worse with every page.
|
||||
|
||||
- The **home page** described a Storage App that opens "text, hex, captures, tracks and update files". It had been showing pictures since v0.16.0 and serving the card to phones since v0.18.0.
|
||||
- The **Notes** card didn't say that a note can be any size, which it can since v0.15.0.
|
||||
- **Settings**, in the guide, had no VPN row. The **Status Bar** table had no `VPN`.
|
||||
- There was **no how-to** for anything built since: nothing on moving files with a phone, nothing on screenshots.
|
||||
- The **README** opened by calling this "a Meshtastic-compatible mesh messenger". It listens to a mesh. It has never sent a message.
|
||||
- Every milestone document had a **status line** from days ago. One still said a feature was "in a pull request" that had long been merged and released.
|
||||
|
||||
None of this was neglect in the usual sense. Each feature *had* been documented: its own guide page, its section in the README, its design notes. That was the trap. Every pull request looked finished because the page about the new thing was there. Nobody was looking at the pages about the old things, which is where a reader starts.
|
||||
|
||||
Six releases went out like that.
|
||||
|
||||
What changed isn't a resolution to be more careful, which lasts a week. It's a list, the same one every time: the home page's cards, every Settings row, the Status Bar, a how-to for anything a person would want to do, the FAQ, the glossary, the README's opening, the status lines, a screenshot of each new screen. A feature isn't done until each of those has been asked "does the new thing show here?"
|
||||
|
||||
The catch-up went into the same pull request as the VPN: three new how-tos, five new screenshots, and a README that starts with what the firmware does today.
|
||||
|
||||
The two releases after it were documented as they were built. That's two. Ask me again at twenty.
|
||||
|
||||
## Nine commands for a bad network day
|
||||
|
||||
A VPN, addresses you can set by hand, a file server: this device now has enough networking to have network problems. And until today, the only thing it could tell you was `wifi status`.
|
||||
|
||||
{% table() %}
|
||||
| Command | Tells you |
|
||||
|---|---|
|
||||
| `ping` | Does it answer, and how fast |
|
||||
| `nslookup` | A name's addresses, which DNS server answered, in how long |
|
||||
| `port` | A TCP port: open, refused, or silent |
|
||||
| `traceroute` | The routers on the way |
|
||||
| `tls` | A certificate: who for, who by, until when, and whether *this device* trusts it |
|
||||
| `ntp` | A time server's clock against this one |
|
||||
| `ifconfig` | The interfaces, and **which one is the default route** |
|
||||
| `arp` | The neighbours on the Wi-Fi |
|
||||
| `netstat` | What the device itself listens on |
|
||||
{% end %}
|
||||
|
||||
They run in the Shell and over both consoles. The slow ones each get a task of their own and print as they go; `cancel` stops one.
|
||||
|
||||
{{ figure(src="shell.png", alt="The Shell at 2x with VPN in the Status Bar: ping: 1 4 ms, 2 7 ms, 3 4 ms, then ping: 3 sent, 3 back, 0% lost, 4/5/7 with ms wrapped onto the next line; then nslookup roro9stack.net, 10.9.0.1 answered in 6 ms, 65.21.233.110.", width=480, height=270, caption="The first build, in the Shell. The ping summary is one character too long for the screen and drops its `ms` onto a line of its own. It reads `3/3 back, 0% lost, 4/5/7 ms` now.") }}
|
||||
|
||||
Three things I like about them.
|
||||
|
||||
**`nslookup` asks the server itself.** The system's resolver gives you an address and nothing else. This sends its own question over UDP, so it can say *which* server answered and how long it took, and it can ask a different server to compare. When a name doesn't resolve, that's the whole diagnosis.
|
||||
|
||||
**`tls` checks nothing, then checks everything.** IRC, Gemini and updates all fail with "TLS error" and no way to see why. `tls` makes a handshake that accepts any certificate, so that a bad one can be looked at, and then judges it itself against the roots this device actually trusts:
|
||||
|
||||
{% code(caption="Four servers, four verdicts.") %}
|
||||
```
|
||||
tls: for git.twis.la, by YE2
|
||||
tls: valid 2026-09-15 to 2026-12-14, 67 days left
|
||||
tls: this device trusts it
|
||||
|
||||
tls: for geminiprotocol.net, by geminiprotocol.net
|
||||
tls: NOT trusted here: not signed by a root this device has
|
||||
|
||||
tls: valid 2015-04-09 to 2015-04-12, EXPIRED 4197 days ago
|
||||
tls: NOT trusted here: expired, not signed by a root this device has
|
||||
|
||||
tls: for *.badssl.com, by YR1
|
||||
tls: NOT trusted here: not for that name
|
||||
```
|
||||
{% end %}
|
||||
|
||||
The second one is fine, by the way: a Gemini capsule signs its own certificate, and the browser trusts it on first sight.
|
||||
|
||||
**A ping with a size finds a tunnel's MTU.** This is the one I didn't expect to use within the hour:
|
||||
|
||||
{% code(caption="Through my tunnel. 1392 bytes plus 28 of headers is 1420, WireGuard's usual.") %}
|
||||
```
|
||||
$ ping 9.9.9.9 2 1392
|
||||
ping: 2/2 back, 0% lost, 27/30/33 ms
|
||||
$ ping 9.9.9.9 2 1393
|
||||
ping: 0/2 back, 100% lost
|
||||
```
|
||||
{% end %}
|
||||
|
||||
One byte. And `ifconfig` on the same device, same minute:
|
||||
|
||||
{% code() %}
|
||||
```
|
||||
ifconfig: vpn 10.9.0.2/32 mtu 1420, up, default route
|
||||
ifconfig: wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up
|
||||
ifconfig: dns 10.9.0.1
|
||||
```
|
||||
{% end %}
|
||||
|
||||
"default route" on the `vpn` line is the answer to the first question anybody has with a VPN up.
|
||||
|
||||
## All of them, on the device
|
||||
|
||||
Typed on the Cardputer's own keyboard, in the Shell, with the tunnel up. `VPN` in the Status Bar is the tunnel; everything below went through it.
|
||||
|
||||
{{ figure(src="ifconfig.png", alt="The Shell: ifconfig prints vpn 10.9.0.2/32 mtu 1420, up, default route; wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up; dns 10.9.0.1.", width=480, height=270, caption="`ifconfig`. The first line answers the first question: with the tunnel up, everything leaves through it.") }}
|
||||
|
||||
{{ figure(src="ping.png", alt="The Shell: ping 9.9.9.9 3 prints 9.9.9.9, 56 bytes, then 1 25 ms, 2 25 ms, 3 33 ms, and 3/3 back, 0% lost, 25/27/33 ms.", width=480, height=270, caption="`ping`, with a count. The summary fits on one line now.") }}
|
||||
|
||||
{{ figure(src="nslookup.png", alt="The Shell: nslookup www.wikipedia.org prints 10.9.0.1 answered in 293 ms, www.wikipedia.org is dyna.wikimedia.org, and 185.15.59.224.", width=480, height=270, caption="`nslookup`. Which server answered, how long it took, the alias the name really is, and the address.") }}
|
||||
|
||||
{{ figure(src="port.png", alt="The Shell: port git.twis.la 443 prints 65.21.233.110:443 open, 48 ms.", width=480, height=270, caption="`port`. Open, in 48 ms. The other two answers are `refused` and nothing at all for five seconds.") }}
|
||||
|
||||
{{ figure(src="tls.png", alt="The Shell after tls git.twis.la: for git.twis.la, by YE2; valid 2026-09-15 to 2026-12-14, 67 days left; this device trusts it; sha256 and 64 hexadecimal digits over two lines.", width=480, height=270, caption="`tls`. The verdict, and the fingerprint that a Gemini pin is made of. The first line scrolled off: the handshake took 0.7 s.") }}
|
||||
|
||||
{{ figure(src="ntp.png", alt="The Shell: ntp prints pool.ntp.org (162.159.200.123), stratum 3, 23 ms away, and this clock is right, to 0.1 s.", width=480, height=270, caption="`ntp`. The clock gates TLS and the VPN, so it's worth being able to ask.") }}
|
||||
|
||||
{{ figure(src="netstat.png", alt="The Shell: netstat prints tcp 2323 listens (Debug Console), tcp 3232 listens (updates), tcp 172.16.42.25:2323 - 172.16.42.249:51552, udp 49974, udp 49973, udp 68 (DHCP).", width=480, height=270, caption="`netstat`. What the device offers the network right now. The one connection is the Debug Console session that pressed these keys.") }}
|
||||
|
||||
No picture of `traceroute` or `arp`: the first would list my provider's routers and the second my machines' hardware addresses, and neither belongs on a website. The traceroute through the tunnel was nine hops, my own server first.
|
||||
|
||||
## The same mistake, twice, in three hours
|
||||
|
||||
For the file sharing, the testable half of the code went in a file called `web_share.h`, and the service that uses it in another file called `web_share.h`, in another folder. One included the other by name and got itself. I wrote that up in [the last post](/devlog/roro9stack-share/) as one of the two things that went wrong.
|
||||
|
||||
For the network commands, the testable half went in `net_tools.h`, and the service in `net_tools.h`.
|
||||
|
||||
Same error message. Same fix. I'd like to say the second time took less long to spot.
|
||||
|
||||
Two smaller ones:
|
||||
|
||||
- **A refused connection isn't called refused.** lwIP reports it as "reset". My first `port` command told me a port on my own PC had "no route to it".
|
||||
- **My test tool lied about concurrency.** The script that sends commands waits "until the console is quiet". A ping prints every second, so the console was never quiet, so my "second command while a ping runs" was sent after the ping had finished. It ran, and I briefly believed the one-at-a-time guard didn't work.
|
||||
|
||||
## What I didn't check
|
||||
|
||||
- **From far away.** My real server answered, but the device was on the server's own network, reaching it by its public name.
|
||||
- **Roaming** from one Wi-Fi to another with the tunnel wanted.
|
||||
- **IRC through the tunnel.**
|
||||
- `tls` with IRC connected, when there shouldn't be the memory and it should say so.
|
||||
- The network commands **without** the VPN: every test went through the tunnel or to the local network.
|
||||
|
||||
## By the numbers
|
||||
|
||||
{% table() %}
|
||||
| | |
|
||||
|---|---|
|
||||
| Releases | 3 |
|
||||
| Heap a WireGuard tunnel costs | 1.8 KB |
|
||||
| Flash it costs | 63 KB |
|
||||
| Calls into the library before the device stopped | 1 |
|
||||
| Lines to fix that | 3 |
|
||||
| Ways lwIP can route a packet | 2 |
|
||||
| Releases that shipped with the docs behind | 6 |
|
||||
| How-tos written in one sitting to catch up | 3 |
|
||||
| Network commands | 9 |
|
||||
| Flash they cost | 20 KB |
|
||||
| Bytes between a ping that crosses my tunnel and one that doesn't | 1 |
|
||||
| Times I gave two headers the same name | 2 |
|
||||
| Host tests | 546 |
|
||||
{% end %}
|
||||
|
||||
## Where it stands
|
||||
|
||||
{% steps() %}
|
||||
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||
|
||||
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||
|
||||
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||
|
||||
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||
|
||||
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||
|
||||
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||
|
||||
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||
|
||||
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||
|
||||
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||
|
||||
10. ~~Pictures, a screenshot key, the card in a phone's browser.~~ v0.16.0 to v0.18.0, [Press w](/devlog/roro9stack-share/).
|
||||
|
||||
11. ~~A WireGuard tunnel, and the documentation caught up.~~ v0.19.0, this post.
|
||||
|
||||
12. ~~Nine commands for a bad network day.~~ v0.20.0 and v0.21.0, this post.
|
||||
|
||||
13. Next: M4, the mesh, which still wants a second node. And maybe SSH, now that there is a tunnel to reach things through.
|
||||
{% end %}
|
||||
|
||||
{% signoff() %}
|
||||
The VPN took a library, a lock and an evening. The documentation took a look. I'd spent a day shipping features to a website that described yesterday's firmware, with every pull request looking complete because its own page was there. The tunnel carries exactly 1420 bytes, and I know that because the device told me.
|
||||
{% end %}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -53,16 +53,34 @@ 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. The SSH App connects out to the server you name and listens for nothing.
|
||||
|
||||
**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/).
|
||||
|
||||
## Can it log in to my server?
|
||||
|
||||
Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both.
|
||||
|
||||
## How do I copy files to and from my phone?
|
||||
|
||||
In the Storage App, press <kbd>w</kbd>: 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?
|
||||
|
||||
<kbd>Fn</kbd> + <kbd>p</kbd>, on any screen. The picture goes to `/screenshots` on the SD card. See [Take a screenshot](/howto/screenshot/).
|
||||
|
||||
## The network doesn't work: how do I find out why?
|
||||
|
||||
From the device itself, in the Shell: `ifconfig`, `ping`, `nslookup`, `port` and `traceroute`. [When the network doesn't work](/howto/network-check/) puts them in order.
|
||||
|
||||
## 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/).
|
||||
|
||||
@@ -10,6 +10,10 @@ 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:** <kbd>Fn</kbd> + <kbd>h</kbd> for the keys, <kbd>Fn</kbd> + <kbd>p</kbd> for a screenshot, and <kbd>Fn</kbd> + <kbd>`</kbd> to go back to the Launcher.
|
||||
|
||||
**Two things keep running when you leave their App:** IRC stays connected, and an [SSH](/guide/ssh/) session stays open.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,8 @@ 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 |
|
||||
| `SSH` | An [SSH session](/guide/ssh/) is open, whatever App is in front |
|
||||
| `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 |
|
||||
|
||||
@@ -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 = 15
|
||||
[extra]
|
||||
tag = "Reference"
|
||||
+++
|
||||
|
||||
@@ -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 <kbd>Fn</kbd> + <kbd>h</kbd> 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"]) }}
|
||||
|
||||
@@ -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.
|
||||
@@ -38,10 +39,29 @@ Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command r
|
||||
|
||||
Type an App's name **with a capital letter** to open it, without going back to the Launcher:
|
||||
|
||||
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `System` `Settings`
|
||||
`Irc` `Wifi` `Gnss` `Gemini` `Lora` `Storage` `Notes` `Ssh` `System` `Settings`
|
||||
|
||||
The capital is the difference: every command is in small letters, every App starts with a capital. <kbd>Tab</kbd> completes them too.
|
||||
|
||||
## The network
|
||||
|
||||
For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes; one runs at a time, and `cancel` stops it.
|
||||
|
||||
| Command | Tells you |
|
||||
|---|---|
|
||||
| `ping 10.9.0.1` | Whether a host answers, and how fast. A count and a size may follow: `ping 10.9.0.1 10 1392` |
|
||||
| `nslookup roro9stack.net` | A name's addresses, which DNS server answered and in how long. Another server may follow: `nslookup roro9stack.net 9.9.9.9` |
|
||||
| `port git.twis.la 443` | Whether a TCP port is **open**, **refused** (the host is there, nothing listens) or silent (down, or filtered) |
|
||||
| `traceroute 9.9.9.9` | The routers on the way, one a line |
|
||||
| `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) |
|
||||
| `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow |
|
||||
| `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session |
|
||||
| `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers |
|
||||
| `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all |
|
||||
| `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now |
|
||||
|
||||
A host can be a name or an address. [When the network doesn't work](/howto/network-check/) puts them in order.
|
||||
|
||||
## Deleting
|
||||
|
||||
`rm` works as it does on Unix, with one addition: it asks.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
+++
|
||||
title = "SSH"
|
||||
description = "A terminal on another machine: log in to a server with a password or with the device's own key, and run a shell, vim or top on the Cardputer's screen."
|
||||
weight = 13
|
||||
[extra]
|
||||
tag = "SSH"
|
||||
screens = ["ssh.png", "ssh-trust.png", "ssh-terminal.png", "ssh-key.png", "ssh-changed.png"]
|
||||
+++
|
||||
|
||||
The SSH App opens a **terminal on another machine**: a server, a Raspberry Pi, a router. What you type goes there, and what its programs print is drawn here: a shell, `less`, `top`, `nano`, `vim`.
|
||||
|
||||
It is a client and nothing more: one session at a time, to a shell. No file transfer, no port forwarding, no jump hosts.
|
||||
|
||||
## Connecting
|
||||
|
||||
The App opens on the hosts it has connected to before, the last one first, then **New connection** and **This device's key**.
|
||||
|
||||
1. Choose **New connection** (or press <kbd>n</kbd>) and type **`user@host`**, or `user@host:port` when the port isn't 22. The host is a name or an address.
|
||||
2. **The first time, it shows the server's fingerprint** and asks whether that is the right server. Compare it with what the server's owner gives you (`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server prints it), and choose **Trust it**. It is remembered, and not asked again.
|
||||
3. **Type the password** when it asks. It is used for this login and kept nowhere: not in the device, not on the card.
|
||||
|
||||
A host you logged in to is kept in the list: <kbd>Enter</kbd> connects to it again, <kbd>d</kbd> forgets it, and its fingerprint with it. Up to eight are kept.
|
||||
|
||||
From the [Shell](/guide/shell/), `ssh user@host` does the same and opens this App.
|
||||
|
||||
## If the server has changed
|
||||
|
||||
A server that shows **a different key than the one remembered** gets a warning instead of a question: **THE SERVER'S KEY CHANGED**. Either the server was reinstalled, or something between you and it is answering in its place. The safe answer, **Cancel**, is the one selected. Choose **Replace** only when you know why the key changed.
|
||||
|
||||
## Without a password: this device's key
|
||||
|
||||
**This device's key** makes a key pair for the device (Ed25519). The private half stays inside the device and is never shown. The public half is one line of text:
|
||||
|
||||
- it is shown on that page,
|
||||
- written to the card as **`/ssh/id_ed25519.pub`**,
|
||||
- and printed by `ssh status` in the Shell.
|
||||
|
||||
Add that line to `~/.ssh/authorized_keys` on a server, and the device logs in there with no password. See [Log in to a server without a password](/howto/ssh-key/).
|
||||
|
||||
Making a **new key** replaces the old one: servers that knew the old one ask for a password again.
|
||||
|
||||
The key has no passphrase, so **whoever holds the device can log in wherever its key is accepted**. Keys made elsewhere can't be imported.
|
||||
|
||||
## In the terminal
|
||||
|
||||
Every key goes to the other machine, with these differences:
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| <kbd>`</kbd> | **Esc** (the key is printed "esc") |
|
||||
| <kbd>Alt</kbd> + <kbd>`</kbd> | types a backtick |
|
||||
| <kbd>Fn</kbd> + <kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd> | the arrows |
|
||||
| <kbd>Fn</kbd> + <kbd>Shift</kbd> + <kbd>;</kbd> <kbd>.</kbd> | Page Up, Page Down |
|
||||
| <kbd>Ctrl</kbd> + a letter | Ctrl+C, Ctrl+D, Ctrl+Z and the rest |
|
||||
| <kbd>Alt</kbd> + a key | that key with Alt (Esc first) |
|
||||
| <kbd>Alt</kbd> + <kbd>;</kbd> <kbd>.</kbd> | scroll back and forward through the last 100 lines |
|
||||
| <kbd>Ctrl</kbd> + <kbd>+</kbd> <kbd>-</kbd> | larger and smaller text |
|
||||
| <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>q</kbd> | disconnect |
|
||||
| <kbd>Fn</kbd> + <kbd>`</kbd> | back to the Launcher, **leaving the session running** |
|
||||
|
||||
**Leaving doesn't disconnect.** Go to the Launcher or to another App and the session goes on; `SSH` shows in the [Status Bar](/guide/basics/#the-status-bar) for as long as it does. Open the SSH App again and you are back in it. `exit` on the other machine, or <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>q</kbd> here, ends it.
|
||||
|
||||
### The size of the text
|
||||
|
||||
Five sizes, changed with <kbd>Ctrl</kbd> + <kbd>+</kbd> and <kbd>Ctrl</kbd> + <kbd>-</kbd>; the other machine is told the new size at once, and the choice is kept.
|
||||
|
||||
| Text | Columns × rows |
|
||||
|---|---|
|
||||
| tiny | 60 × 20 |
|
||||
| small (to start with) | 48 × 15 |
|
||||
| medium | 40 × 12 |
|
||||
| normal | 40 × 9 |
|
||||
| large | 26 × 8 |
|
||||
|
||||
A terminal is usually 80 columns wide, and this screen isn't: long lines wrap, and programs that want 80 columns look cramped. `top`, `vim` and `less` adapt.
|
||||
|
||||
### What it shows, and what it doesn't
|
||||
|
||||
- Sixteen colours, bold as a brighter colour, reverse video.
|
||||
- Western European letters (é, ñ, ü). Other characters show as `?`, and box-drawing lines as `+`, `-` and `|`.
|
||||
- No mouse.
|
||||
|
||||
## Memory
|
||||
|
||||
A session takes about **50 KB** of the device's 100 KB while it is open, and gives it back when it ends. That is too much to share with a secure connection:
|
||||
|
||||
- **It doesn't start with less than 75 KB free.** With IRC connected, or a Gemini page open, it says "Not enough memory: close IRC or a Gemini page".
|
||||
- **While a session is open, IRC doesn't connect:** it says so in its buffer and tries again later.
|
||||
|
||||
See [When a connection says "not enough memory"](/howto/not-enough-memory/).
|
||||
|
||||
## From the Shell
|
||||
|
||||
```
|
||||
ssh user@host connect, in the SSH App
|
||||
ssh user@host:2222 on another port
|
||||
ssh status the session, and this device's public key
|
||||
ssh stop end the session
|
||||
```
|
||||
|
||||
## Keys
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["ssh", "ssh-terminal", "ssh-key"]) }}
|
||||
@@ -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. <kbd>Enter</kbd> opens a folder, Back goes up, and <kbd>s</kbd> 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 <kbd>w</kbd> 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.
|
||||
|
||||
@@ -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 = 14
|
||||
[extra]
|
||||
tag = "Firmware"
|
||||
screens = ["update.png"]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
+++
|
||||
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.
|
||||
|
||||
To see it work: `ifconfig` shows the tunnel and whether it is the default route, and `ping`, `nslookup`, `port` and `traceroute` go the way real traffic goes. See [When the network doesn't work](/howto/network-check/).
|
||||
|
||||
## 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"]) }}
|
||||
@@ -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, log in to a server with the device's SSH key, take a screenshot, find out why the network doesn't work, 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"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
+++
|
||||
title = "When the network doesn't work"
|
||||
description = "Find out where it stops, from the device itself: the Wi-Fi, the gateway, the names, the port, the route, the tunnel."
|
||||
weight = 12
|
||||
[extra]
|
||||
tag = "Network"
|
||||
+++
|
||||
|
||||
Open the [Shell](/guide/shell/) and go down this list. Each step says what a good answer looks like; the first bad one is where the trouble is.
|
||||
|
||||
1. **`ifconfig`**: is there an address, and where does traffic go?
|
||||
|
||||
```
|
||||
ifconfig: vpn 10.9.0.2/32 mtu 1420, up, default route
|
||||
ifconfig: wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up
|
||||
ifconfig: dns 10.9.0.1
|
||||
```
|
||||
|
||||
No `wifi` line, or `down`: it is the Wi-Fi itself ([Settings](/guide/settings/#wi-fi)). "default route" says which way everything leaves: on `vpn`, everything goes through the tunnel.
|
||||
|
||||
2. **`ping` the gateway** (the `gw` address): is the local network there?
|
||||
|
||||
```
|
||||
ping: 4/4 back, 0% lost, 3/3/4 ms
|
||||
```
|
||||
|
||||
Nothing back: `arp` shows whether the gateway was ever heard.
|
||||
|
||||
3. **`ping 9.9.9.9`**: is the internet there, without names? If the gateway answers and this doesn't, the trouble is beyond your network, or in the tunnel.
|
||||
|
||||
4. **`nslookup roro9stack.net`**: do names resolve?
|
||||
|
||||
```
|
||||
nslookup: 10.9.0.1 answered in 6 ms
|
||||
nslookup: 65.21.233.110
|
||||
```
|
||||
|
||||
"no answer from": that DNS server is unreachable. Try another to compare: `nslookup roro9stack.net 9.9.9.9`. If that one answers, change the servers ([DNS and NTP](/guide/settings/#dns-and-ntp)).
|
||||
|
||||
5. **`port <host> <port>`**: is the service there? `open` is good. `refused` means the machine is up and nothing listens on that port. "no answer" means it is down, or a firewall drops it.
|
||||
|
||||
6. **`traceroute <host>`**: where does it stop? The last router that answers is the last one that works.
|
||||
|
||||
7. **`tls <host>`**: a secure connection fails ("TLS error")? This shows the certificate and the verdict:
|
||||
|
||||
```
|
||||
tls: git.twis.la:443 answered in 709 ms
|
||||
tls: for git.twis.la, by YE2
|
||||
tls: valid 2026-09-15 to 2026-12-14, 67 days left
|
||||
tls: this device trusts it
|
||||
```
|
||||
|
||||
"NOT trusted here" comes with the reason: **expired**, **not for that name**, or **not signed by a root this device has**. The last is normal for a Gemini capsule, which signs its own certificate, and a problem for an update server. It needs about 70 KB of free memory: close IRC first if it says so.
|
||||
|
||||
8. **`ntp`**: is the clock right? A clock that is wrong by more than a few minutes breaks secure connections and the VPN.
|
||||
|
||||
```
|
||||
ntp: pool.ntp.org (195.72.61.39), stratum 2, 50 ms away
|
||||
ntp: this clock is right, to 0.1 s
|
||||
```
|
||||
|
||||
## What is the device itself offering?
|
||||
|
||||
`netstat` lists what it listens on, and who is connected:
|
||||
|
||||
```
|
||||
netstat: tcp 3232 listens (updates)
|
||||
netstat: tcp 2323 listens (Debug Console)
|
||||
netstat: tcp 172.16.42.25:2323 - 172.16.42.249:51858
|
||||
```
|
||||
|
||||
The update port is always there (updates are signed). The Debug Console and sharing appear only while you have them on.
|
||||
|
||||
## With the VPN up
|
||||
|
||||
- `ifconfig` shows the `vpn` line, and "default route" on it if everything goes through.
|
||||
- `ping` the server's tunnel address first: that is the tunnel itself.
|
||||
- **Finding the tunnel's real packet size:** `ping 9.9.9.9 2 1392` (1392 bytes, plus 28 of headers, is 1420: WireGuard's usual). If that comes back and 1393 doesn't, the tunnel carries 1420. If 1392 doesn't come back either, try smaller: something on the way allows less, and the server's configuration wants a lower `MTU`.
|
||||
|
||||
## Good to know
|
||||
|
||||
- `cancel` stops a `ping` or a `traceroute`.
|
||||
- Some hosts and routers don't answer pings or traceroutes at all; a silent hop (`*`) in the middle of a route that goes on is normal.
|
||||
- The same commands work over the [Debug Console](/dev/debug/).
|
||||
@@ -8,7 +8,7 @@ tag = "Memory"
|
||||
|
||||
## What you see
|
||||
|
||||
Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or an update check or install makes IRC disconnect for a moment.
|
||||
Opening a Gemini page fails with *not enough memory: stop IRC or retry*. Or SSH says *Not enough memory: close IRC or a Gemini page*, or IRC says *not enough memory: close the SSH session*. Or an update check or install makes IRC disconnect for a moment.
|
||||
|
||||
## What to do
|
||||
|
||||
@@ -20,6 +20,19 @@ 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 |
|
||||
|---|---|
|
||||
| An [SSH](/guide/ssh/) session, from login until it ends | 50 KB: IRC waits while it is open, and it doesn't start under 75 KB free |
|
||||
| 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/).
|
||||
|
||||
@@ -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 <kbd>w</kbd>. 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).
|
||||
@@ -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 <kbd>Fn</kbd> + <kbd>p</kbd>. 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 <kbd>Enter</kbd> on the file. <kbd>Enter</kbd> again shows it at its own size.
|
||||
3. **To get it out:** in Storage press <kbd>w</kbd> 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.
|
||||
@@ -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,9 @@ 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/) (<kbd>Fn</kbd> + <kbd>p</kbd>) | `/screenshots` | `.png`, named by date and time |
|
||||
| The public half of the device's [SSH key](/guide/ssh/#without-a-password-this-device-s-key) | `/ssh/id_ed25519.pub` | one line, for a server's `authorized_keys`; safe to copy anywhere |
|
||||
| A VPN configuration waiting to be imported | `/vpn/wg0.conf` | you put it there; delete it once imported |
|
||||
|
||||
## Rules worth knowing
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
+++
|
||||
title = "Log in to a server without a password"
|
||||
description = "Make the Cardputer's own SSH key, put its public half on a server, and connect with no password to type."
|
||||
weight = 13
|
||||
[extra]
|
||||
tag = "SSH"
|
||||
+++
|
||||
|
||||
Typing a password on a small keyboard, every time, gets old. With a key, the server recognises the device.
|
||||
|
||||
1. On the Cardputer, open **SSH → This device's key** and press <kbd>Enter</kbd>. It makes the key and shows its **public half**: one line starting with `ssh-ed25519`.
|
||||
2. Get that line to the server. It was also written to the card as **`/ssh/id_ed25519.pub`**, so the easy way is the phone: in **Storage** press <kbd>w</kbd>, open the page ([Move files with your phone](/howto/phone-files/)) and download the file. Or log in to the server with your password, from the Cardputer or anything else, and paste it.
|
||||
3. On the server, add the line to the end of **`~/.ssh/authorized_keys`** of the user you log in as:
|
||||
|
||||
```
|
||||
cat id_ed25519.pub >> ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything.
|
||||
|
||||
## If it still asks for a password
|
||||
|
||||
| Check | How |
|
||||
|---|---|
|
||||
| The line is whole, on one line | `ssh-ed25519`, a long word, then `roro9stack` |
|
||||
| The file's permissions | `chmod 700 ~/.ssh` and `chmod 600 ~/.ssh/authorized_keys`: OpenSSH ignores a file others can write |
|
||||
| It is the right user's file | the `user` of `user@host` |
|
||||
| The server takes keys | `PubkeyAuthentication yes` in its `sshd_config` (the default) |
|
||||
| You made a new key since | a new key replaces the old one: put the new line on the server |
|
||||
|
||||
## Worth knowing
|
||||
|
||||
The private half never leaves the device and has no passphrase: **whoever holds the Cardputer can log in wherever its key is accepted**. If the device is lost, remove its line from `authorized_keys` on your servers. More in the [SSH](/guide/ssh/) page.
|
||||
@@ -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 <kbd>w</kbd>; 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
|
||||
|
||||
On the device, in the [Shell](/guide/shell/): `vpn status`, then `ifconfig` (a `vpn` line, up) and `ping` the server's tunnel address. Or from another machine on the VPN, ping the Cardputer's tunnel address (the `Address` line of the file). More in [When the network doesn't work](/howto/network-check/).
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -357,6 +357,48 @@ rows = [
|
||||
["Tab", "the file as hex"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "vpn"
|
||||
title = "Settings, VPN"
|
||||
rows = [
|
||||
["Enter", "switch, import, forget"],
|
||||
["; .", "up, down"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh"
|
||||
title = "SSH, the hosts"
|
||||
rows = [
|
||||
["Enter", "connect, open"],
|
||||
["; .", "up, down"],
|
||||
["n", "a new connection"],
|
||||
["d", "forget this host"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh-terminal"
|
||||
title = "SSH, the terminal"
|
||||
rows = [
|
||||
["`", "Esc"],
|
||||
["Alt `", "a backtick"],
|
||||
["Fn ; . , /", "the arrows"],
|
||||
["Fn Shift ; .", "Page Up, Page Down"],
|
||||
["Ctrl a..z", "Ctrl+C and the rest"],
|
||||
["Alt ; .", "scroll back, forward"],
|
||||
["Ctrl + -", "larger, smaller text"],
|
||||
["Ctrl Alt q", "disconnect"],
|
||||
["Fn `", "leave it running"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh-key"
|
||||
title = "SSH, this device's key"
|
||||
rows = [
|
||||
["Enter", "make a key, or a new one"],
|
||||
["w", "write the public half to the card"],
|
||||
["`", "back"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "notes"
|
||||
title = "Notes, the list"
|
||||
|
||||
@@ -38,3 +38,53 @@ 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 = "ssh.png"
|
||||
alt = "The SSH App's list: a saved host, tester at 172.16.42.249 port 2222, then New connection and This device's key"
|
||||
caption = "SSH, the hosts"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-trust.png"
|
||||
alt = "A dialog over the SSH App: A server not met before, the address 172.16.42.249, and a SHA256 fingerprint on two lines; buttons Cancel, selected, and Trust it"
|
||||
caption = "SSH, a server met for the first time"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-terminal.png"
|
||||
alt = "The SSH App's terminal showing top on a remote machine: uptime, tasks, memory, then a reverse-video header and five processes. SSH shows in the Status Bar"
|
||||
caption = "SSH, top on another machine"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-key.png"
|
||||
alt = "This device's key: its public half, for a server's authorized_keys file, a line starting ssh-ed25519; it is also in /ssh/id_ed25519.pub, and the private half never leaves"
|
||||
caption = "SSH, this device's key"
|
||||
|
||||
[[screen]]
|
||||
file = "ssh-changed.png"
|
||||
alt = "A dialog over the SSH App: THE SERVER'S KEY CHANGED. Someone in between? Now it is, and a SHA256 fingerprint; buttons Cancel, selected, and Replace"
|
||||
caption = "SSH, a server whose key changed"
|
||||
|
||||
[[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)"
|
||||
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -68,7 +68,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="cards-note">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 <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
<p class="cards-note">Plus a <a class="accent-link" href="/guide/shell/">Shell</a> that runs the firmware's commands on the device, an <a class="accent-link" href="/guide/ssh/">SSH client</a> with a real terminal, a <a class="accent-link" href="/guide/vpn/">WireGuard VPN</a>, a screenshot key that works on every screen, and Settings, with its Wi-Fi and Firmware pages. Every App has a page in the <a class="accent-link" href="/guide/">user guide</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="wrap" id="screens" aria-labelledby="screens-title">
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<KeyHelp>& 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
#include "apps/ssh_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <SD.h>
|
||||
|
||||
#include "app_keys.h"
|
||||
#include "text_wrap.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kMessageMs = 4000;
|
||||
constexpr uint32_t kFrameMs = 60; // a busy terminal is drawn this often at most
|
||||
|
||||
std::string masked(size_t n) { return std::string(n, '*'); }
|
||||
|
||||
// The terminal keeps characters as the fonts have them, one byte each; drawing wants UTF-8.
|
||||
void appendUtf8(std::string& out, uint8_t ch) {
|
||||
if (ch < 0x80) out += static_cast<char>(ch);
|
||||
else {
|
||||
out += static_cast<char>(0xC0 | (ch >> 6));
|
||||
out += static_cast<char>(0x80 | (ch & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
// Alt with ; and . looks back and forward, as in the Shell (Q258): lines to move, or 0.
|
||||
int scrollStep(const KeyEvent& e) {
|
||||
if (!e.alt || e.ctrl) return 0;
|
||||
if (e.key == Key::Up || (e.key == Key::Char && e.ch == ';')) return 4;
|
||||
if (e.key == Key::Down || (e.key == Key::Char && e.ch == '.')) return -4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint16_t colour(int index) {
|
||||
uint8_t r, g, b;
|
||||
term::colourRgb(index, r, g, b);
|
||||
return lgfx::color565(r, g, b);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Ctrl with + and - step through these (Q257): from 60 columns by 20 rows to 26 by 8.
|
||||
const SshApp::Font SshApp::kFonts[5] = {{&fonts::tiny, 4, 6}, {&fonts::small, 5, 8}, {&fonts::medium, 6, 10}, {&fonts::body, 6, 13}, {&fonts::large, 9, 15}};
|
||||
|
||||
void SshApp::grid(int& cols, int& rows) const {
|
||||
const auto& area = theme::kContent;
|
||||
cols = area.w / kFonts[font_].w;
|
||||
rows = area.h / kFonts[font_].h;
|
||||
}
|
||||
|
||||
void SshApp::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void SshApp::onEnter() {
|
||||
font_ = settings_.getInt(Setting::SshFont);
|
||||
hosts_ = term::SshHosts(settings_.getString(Setting::SshHosts)).list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
message_.clear();
|
||||
scroll_ = 0;
|
||||
// A session that was left running is found again.
|
||||
SshService::State s = ssh_.state();
|
||||
view_ = s == SshService::State::Idle || (s == SshService::State::Ended && view_ != View::Session) ? View::Hosts : View::Session;
|
||||
shownState_ = SshService::State::Idle;
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void SshApp::connect(const term::SshTarget& target) {
|
||||
int cols, rows;
|
||||
grid(cols, rows);
|
||||
std::string why = ssh_.connect(target, cols, rows);
|
||||
if (!why.empty()) return say(why);
|
||||
password_ = LineEditor(96);
|
||||
scroll_ = 0;
|
||||
view_ = View::Session;
|
||||
shownState_ = SshService::State::Idle;
|
||||
}
|
||||
|
||||
std::string SshApp::connectTo(const std::string& text) {
|
||||
term::SshTarget target;
|
||||
std::string why = term::parseSshTarget(text, target);
|
||||
if (!why.empty()) return why;
|
||||
if (ssh_.state() != SshService::State::Idle && ssh_.state() != SshService::State::Ended) return "a session is open already";
|
||||
font_ = settings_.getInt(Setting::SshFont);
|
||||
message_.clear();
|
||||
connect(target);
|
||||
return view_ == View::Session ? "" : message_;
|
||||
}
|
||||
|
||||
void SshApp::setFont(int index) {
|
||||
index = std::clamp(index, 0, 4);
|
||||
if (index == font_) return;
|
||||
font_ = index;
|
||||
settings_.setInt(Setting::SshFont, font_);
|
||||
int cols, rows;
|
||||
grid(cols, rows);
|
||||
ssh_.resize(cols, rows); // the far end is told, and redraws for the new size
|
||||
scroll_ = 0;
|
||||
say(std::to_string(cols) + " x " + std::to_string(rows));
|
||||
}
|
||||
|
||||
void SshApp::writePublicKey() {
|
||||
std::string line = ssh_.publicKey() + "\n";
|
||||
storage_.runJob([line]() {
|
||||
if (!SD.exists("/ssh")) SD.mkdir("/ssh");
|
||||
File f = SD.open(kPublicKeyPath, FILE_WRITE);
|
||||
if (f) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
|
||||
f.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SshApp::help(std::vector<KeyHelp>& out) const {
|
||||
if (dialog_) return keys::add(out, keys::kDialog);
|
||||
switch (view_) {
|
||||
case View::Hosts: return keys::add(out, keys::kSsh);
|
||||
case View::Entry: return keys::add(out, keys::kText);
|
||||
case View::Key: return keys::add(out, keys::kSshKey);
|
||||
case View::Session:
|
||||
if (ssh_.state() == SshService::State::AskPassword) return keys::add(out, keys::kText);
|
||||
return keys::add(out, keys::kSshTerminal);
|
||||
}
|
||||
}
|
||||
|
||||
const char* SshApp::helpTitle() const {
|
||||
switch (view_) {
|
||||
case View::Entry: return "SSH: a host";
|
||||
case View::Key: return "SSH: this device's key";
|
||||
case View::Session: return "SSH: the terminal";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SshApp::sessionKey(const KeyEvent& e) {
|
||||
SshService::State state = ssh_.state();
|
||||
if (state == SshService::State::AskPassword) {
|
||||
switch (e.key) {
|
||||
case Key::Char: password_.insert(e.ch); break;
|
||||
case Key::Delete: password_.backspace(); break;
|
||||
case Key::Back:
|
||||
ssh_.disconnect();
|
||||
break;
|
||||
case Key::Select: {
|
||||
std::string typed = password_.text();
|
||||
password_ = LineEditor(96);
|
||||
ssh_.answerPassword(typed);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (state == SshService::State::Connecting) {
|
||||
if (e.key == Key::Back) ssh_.disconnect();
|
||||
return true;
|
||||
}
|
||||
if (state == SshService::State::Ended || state == SshService::State::Idle) {
|
||||
if (int step = scrollStep(e)) { // what it said last can still be read
|
||||
int history = 0;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
|
||||
scroll_ = std::clamp(scroll_ + step, 0, history);
|
||||
return true;
|
||||
}
|
||||
ssh_.clear(); // read: its memory goes back
|
||||
view_ = View::Hosts;
|
||||
return true;
|
||||
}
|
||||
// Open: every key is the far end's, but these few.
|
||||
if (e.key == Key::Char && e.ctrl && e.alt && (e.ch == 'q' || e.ch == 'Q')) return ssh_.disconnect(), true;
|
||||
if (e.key == Key::Char && e.ctrl && (e.ch == '=' || e.ch == '+')) return setFont(font_ + 1), true;
|
||||
if (e.key == Key::Char && e.ctrl && (e.ch == '-' || e.ch == '_')) return setFont(font_ - 1), true;
|
||||
if (int step = scrollStep(e)) {
|
||||
int history = 0;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { history = t.altScreen() ? 0 : t.historyCount(); });
|
||||
scroll_ = std::clamp(scroll_ + step, 0, history);
|
||||
return true;
|
||||
}
|
||||
bool app = false;
|
||||
ssh_.withTerminal([&](term::Terminal& t) { app = t.appCursorKeys(); });
|
||||
std::string bytes;
|
||||
switch (e.key) {
|
||||
case Key::Char: bytes = term::encodeKey(term::TermKey::Char, e.ch, e.ctrl, e.alt, app); break;
|
||||
case Key::Select: bytes = term::encodeKey(term::TermKey::Enter, 0, false, e.alt, app); break;
|
||||
case Key::Delete: bytes = term::encodeKey(term::TermKey::Backspace, 0, false, e.alt, app); break;
|
||||
case Key::Tab: bytes = term::encodeKey(term::TermKey::Tab, 0, false, false, app); break;
|
||||
// The key is printed "esc", and here that is what it is. With Alt it types the backtick it also carries.
|
||||
case Key::Back: bytes = e.alt ? "`" : term::encodeKey(term::TermKey::Escape, 0, false, false, app); break;
|
||||
case Key::Up: bytes = term::encodeKey(e.shift ? term::TermKey::PageUp : term::TermKey::Up, 0, false, false, app); break;
|
||||
case Key::Down: bytes = term::encodeKey(e.shift ? term::TermKey::PageDown : term::TermKey::Down, 0, false, false, app); break;
|
||||
case Key::Left: bytes = term::encodeKey(term::TermKey::Left, 0, false, false, app); break;
|
||||
case Key::Right: bytes = term::encodeKey(term::TermKey::Right, 0, false, false, app); break;
|
||||
default: break;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
scroll_ = 0;
|
||||
ssh_.send(bytes);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SshApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (dialog_) {
|
||||
dialog_->onKey(e);
|
||||
int result = dialog_->result();
|
||||
if (result == DialogModel::kPending) return true;
|
||||
Ask asked = ask_;
|
||||
ask_ = Ask::None;
|
||||
dialog_.reset();
|
||||
if (asked == Ask::Trust) ssh_.answerTrust(result == 1);
|
||||
else if (asked == Ask::NewKey && result == 1) {
|
||||
std::string why = ssh_.makeKey();
|
||||
if (why.empty()) writePublicKey();
|
||||
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
|
||||
} else if (asked == Ask::Forget && result == 1) {
|
||||
term::SshHosts saved(settings_.getString(Setting::SshHosts));
|
||||
term::SshTarget gone, other;
|
||||
term::parseSshTarget(hosts_[static_cast<size_t>(list_.selected())], gone);
|
||||
saved.remove(static_cast<size_t>(list_.selected()));
|
||||
settings_.setString(Setting::SshHosts, saved.stored());
|
||||
// Its fingerprint goes too, unless another saved host is the same server: met again, it is asked about again.
|
||||
bool shared = false;
|
||||
for (auto& h : saved.list()) shared = shared || (term::parseSshTarget(h, other).empty() && other.hostPort() == gone.hostPort());
|
||||
if (!shared) {
|
||||
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
|
||||
known.forget(gone.hostPort());
|
||||
settings_.setString(Setting::SshKnown, known.stored());
|
||||
}
|
||||
hosts_ = saved.list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch (view_) {
|
||||
case View::Session: return sessionKey(e);
|
||||
case View::Entry:
|
||||
switch (e.key) {
|
||||
case Key::Char: entry_.insert(e.ch); break;
|
||||
case Key::Delete: entry_.backspace(); break;
|
||||
case Key::Left: entry_.left(); break;
|
||||
case Key::Right: entry_.right(); break;
|
||||
case Key::Back: view_ = View::Hosts; break;
|
||||
case Key::Select: {
|
||||
term::SshTarget target;
|
||||
std::string why = term::parseSshTarget(entry_.text(), target);
|
||||
if (!why.empty()) say(why);
|
||||
else connect(target);
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
case View::Key:
|
||||
if (e.key == Key::Back) view_ = View::Hosts;
|
||||
else if (e.key == Key::Select || (e.key == Key::Char && (e.ch == 'n' || e.ch == 'N'))) {
|
||||
if (!ssh_.hasKey()) {
|
||||
std::string why = ssh_.makeKey();
|
||||
if (why.empty()) writePublicKey();
|
||||
say(why.empty() ? std::string("Made, and written to ") + kPublicKeyPath : why);
|
||||
} else {
|
||||
ask_ = Ask::NewKey;
|
||||
dialog_.reset(new DialogModel({"Cancel", "New key"}));
|
||||
}
|
||||
} else if (e.key == Key::Char && (e.ch == 'w' || e.ch == 'W') && ssh_.hasKey()) {
|
||||
writePublicKey();
|
||||
say(std::string("Written to ") + kPublicKeyPath);
|
||||
}
|
||||
return true;
|
||||
case View::Hosts: {
|
||||
int count = static_cast<int>(hosts_.size());
|
||||
switch (e.key) {
|
||||
case Key::Up: list_.up(); break;
|
||||
case Key::Down: list_.down(); break;
|
||||
case Key::Back: return false;
|
||||
case Key::Select: {
|
||||
int i = list_.selected();
|
||||
if (i < count) {
|
||||
term::SshTarget target;
|
||||
if (term::parseSshTarget(hosts_[static_cast<size_t>(i)], target).empty()) connect(target);
|
||||
} else if (i == count) {
|
||||
entry_ = LineEditor(96);
|
||||
view_ = View::Entry;
|
||||
} else {
|
||||
view_ = View::Key;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Key::Char:
|
||||
if ((e.ch == 'd' || e.ch == 'D') && list_.selected() < count) {
|
||||
ask_ = Ask::Forget;
|
||||
dialog_.reset(new DialogModel({"Cancel", "Forget"}));
|
||||
} else if (e.ch == 'n' || e.ch == 'N') {
|
||||
entry_ = LineEditor(96);
|
||||
view_ = View::Entry;
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SshApp::update(uint32_t nowMs) {
|
||||
if (!message_.empty() && nowMs - messageMs_ >= kMessageMs) {
|
||||
message_.clear();
|
||||
requestRedraw();
|
||||
}
|
||||
if (view_ != View::Session) return;
|
||||
SshService::State state = ssh_.state();
|
||||
if (state != shownState_) {
|
||||
shownState_ = state;
|
||||
if (state == SshService::State::AskTrust && !dialog_) {
|
||||
ask_ = Ask::Trust;
|
||||
dialog_.reset(new DialogModel({"Cancel", ssh_.remembered().empty() ? "Trust it" : "Replace"}));
|
||||
} else if (state != SshService::State::AskTrust && ask_ == Ask::Trust) {
|
||||
dialog_.reset();
|
||||
ask_ = Ask::None;
|
||||
}
|
||||
if (state == SshService::State::Open) { // it got in: a host worth remembering (Q262)
|
||||
term::SshHosts saved(settings_.getString(Setting::SshHosts));
|
||||
saved.used(ssh_.target());
|
||||
settings_.setString(Setting::SshHosts, saved.stored());
|
||||
hosts_ = saved.list();
|
||||
list_.setCount(static_cast<int>(hosts_.size()) + 2);
|
||||
list_.select(0);
|
||||
}
|
||||
requestRedraw();
|
||||
}
|
||||
uint32_t revision = ssh_.revision();
|
||||
if (revision != shownRevision_ && nowMs - lastDrawMs_ >= kFrameMs) {
|
||||
shownRevision_ = revision;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void SshApp::drawHosts(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
int count = static_cast<int>(hosts_.size());
|
||||
widgets::list(
|
||||
c, list_, {area.x, area.y, area.w, 8 * theme::kLineHeight},
|
||||
[&](int i) -> std::string { return i < count ? hosts_[static_cast<size_t>(i)] : i == count ? "New connection" : "This device's key"; },
|
||||
[&](int i) -> std::string { return i < count ? "" : i == count ? ">" : ssh_.hasKey() ? ">" : "none yet"; });
|
||||
}
|
||||
|
||||
void SshApp::drawKey(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
if (!ssh_.hasKey()) {
|
||||
c.drawString("This device has no key yet.", 4, area.y + 4);
|
||||
c.drawString("With one, servers that know it", 4, area.y + 4 + 2 * theme::kLineHeight);
|
||||
c.drawString("ask for no password.", 4, area.y + 4 + 3 * theme::kLineHeight);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString("Enter makes one.", 4, area.y + 4 + 5 * theme::kLineHeight);
|
||||
return;
|
||||
}
|
||||
c.drawString("Its public half, for a server's", 4, area.y + 2);
|
||||
c.drawString("authorized_keys file:", 4, area.y + 2 + theme::kLineHeight);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kText);
|
||||
std::string pub = ssh_.publicKey();
|
||||
int y = area.y + 2 + 2 * theme::kLineHeight + 3;
|
||||
for (size_t at = 0; at < pub.size() && y < area.y + area.h - 30; at += 46, y += 9) c.drawString(pub.substr(at, 46).c_str(), 4, y);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString((std::string("It is also in ") + kPublicKeyPath + " (w writes it").c_str(), 4, area.y + area.h - 28);
|
||||
c.drawString("again). The private half never leaves.", 4, area.y + area.h - 19);
|
||||
}
|
||||
|
||||
void SshApp::drawTerminal(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
const Font& f = kFonts[font_];
|
||||
c.setFont(f.font);
|
||||
c.setTextDatum(top_left);
|
||||
ssh_.withTerminal([&](term::Terminal& t) {
|
||||
int rows = std::min(t.rows(), area.h / f.h), cols = std::min(t.cols(), area.w / f.w);
|
||||
int history = t.altScreen() ? 0 : t.historyCount(), back = std::min(scroll_, history);
|
||||
for (int r = 0; r < rows; r++) {
|
||||
int y = area.y + r * f.h, line = r - back; // negative: a line of the history
|
||||
if (line < 0) {
|
||||
const std::string& text = t.historyLine(history + line);
|
||||
std::string shown;
|
||||
for (size_t i = 0; i < text.size() && static_cast<int>(i) < cols; i++) appendUtf8(shown, static_cast<uint8_t>(text[i]));
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(shown.c_str(), area.x, y);
|
||||
continue;
|
||||
}
|
||||
for (int col = 0; col < cols;) { // one run of the same colours at a time
|
||||
uint8_t attr = t.cell(line, col).attr;
|
||||
std::string run;
|
||||
int from = col;
|
||||
while (col < cols && t.cell(line, col).attr == attr) appendUtf8(run, t.cell(line, col++).ch);
|
||||
if (attr >> 4) c.fillRect(area.x + from * f.w, y, (col - from) * f.w, f.h, colour(attr >> 4));
|
||||
if (run.find_first_not_of(' ') == std::string::npos) continue;
|
||||
c.setTextColor(colour(attr & 15));
|
||||
c.drawString(run.c_str(), area.x + from * f.w, y);
|
||||
}
|
||||
}
|
||||
// The cursor: its cell with the colours swapped.
|
||||
int row = t.cursorRow() + back;
|
||||
if (t.cursorVisible() && row < rows && t.cursorCol() < cols && ssh_.state() == SshService::State::Open) {
|
||||
const term::Cell& cell = t.cell(t.cursorRow(), t.cursorCol());
|
||||
int x = area.x + t.cursorCol() * f.w, y = area.y + row * f.h;
|
||||
c.fillRect(x, y, f.w, f.h, colour(cell.attr & 15));
|
||||
std::string ch;
|
||||
appendUtf8(ch, cell.ch);
|
||||
c.setTextColor(colour(cell.attr >> 4));
|
||||
if (cell.ch != ' ') c.drawString(ch.c_str(), x, y);
|
||||
}
|
||||
if (back) { // looking back: how far
|
||||
std::string where = "-" + std::to_string(back);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextDatum(top_right);
|
||||
c.fillRect(area.x + area.w - 5 * static_cast<int>(where.size()) - 3, area.y, 5 * static_cast<int>(where.size()) + 3, 9, theme::kAccent);
|
||||
c.setTextColor(0x0000);
|
||||
c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SshApp::drawSession(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
SshService::State state = ssh_.state();
|
||||
if (state == SshService::State::Open || (state == SshService::State::Ended && ssh_.opened())) {
|
||||
drawTerminal(c);
|
||||
if (state == SshService::State::Ended) {
|
||||
std::string why = ssh_.status() + ". Any key.";
|
||||
if (why.size() > 47) why = ssh_.status();
|
||||
c.setFont(&fonts::small);
|
||||
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(why.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
return;
|
||||
}
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(ssh_.target().text().c_str(), 4, area.y + 4);
|
||||
if (state == SshService::State::AskPassword) {
|
||||
std::string again = ssh_.status();
|
||||
c.setTextColor(again.empty() ? theme::kText : theme::kWarning);
|
||||
c.drawString(again.empty() ? "Password" : again.c_str(), 4, area.y + 4 + 2 * theme::kLineHeight);
|
||||
LineEditor stars(96);
|
||||
stars.setText(masked(password_.text().size()));
|
||||
widgets::lineEditor(c, stars, {4, area.y + 4 + 3 * theme::kLineHeight + 4, area.w - 8, 0});
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("It isn't kept. ` gives up.", 4, area.y + area.h - 10);
|
||||
return;
|
||||
}
|
||||
// Connecting, or it never got as far as a shell: what it is doing, or why not.
|
||||
bool ended = state == SshService::State::Ended;
|
||||
c.setTextColor(ended ? theme::kWarning : theme::kText);
|
||||
auto lines = wrapText(ssh_.status(), area.w - 8, widgets::bodyMeasure(c));
|
||||
for (size_t i = 0; i < lines.size() && i < 4; i++) c.drawString(lines[i].c_str(), 4, area.y + 4 + static_cast<int>(2 + i) * theme::kLineHeight);
|
||||
if (ended) {
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Any key.", 4, area.y + area.h - 10);
|
||||
}
|
||||
}
|
||||
|
||||
void SshApp::draw(Canvas& c) {
|
||||
lastDrawMs_ = millis();
|
||||
const auto& area = theme::kContent;
|
||||
c.setTextDatum(top_left);
|
||||
switch (view_) {
|
||||
case View::Hosts: drawHosts(c); break;
|
||||
case View::Entry:
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Who, and where", 4, area.y + 4);
|
||||
widgets::lineEditor(c, entry_, {4, area.y + 22, area.w - 8, 0});
|
||||
c.drawString("user@host, or user@host:port", 4, area.y + 44);
|
||||
break;
|
||||
case View::Key: drawKey(c); break;
|
||||
case View::Session: drawSession(c); break;
|
||||
}
|
||||
if (!message_.empty() && !dialog_) {
|
||||
c.setFont(&fonts::small);
|
||||
c.fillRect(area.x, area.y + area.h - 11, area.w, 11, theme::kBackground);
|
||||
c.setTextColor(theme::kWarning);
|
||||
c.drawString(message_.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
if (dialog_ && ask_ == Ask::Trust) {
|
||||
std::string was = ssh_.remembered(), now = ssh_.fingerprint();
|
||||
// Three lines: one of words, and the fingerprint in two halves, since it is longer than a line.
|
||||
std::string shown = now.size() > 25 ? now.substr(0, 25) + " " + now.substr(25) : now;
|
||||
std::string host = ssh_.target().host.size() > 30 ? ssh_.target().host.substr(0, 28) + ".." : ssh_.target().host;
|
||||
if (was.empty()) widgets::dialog(c, "A server not met before", host + " " + shown, *dialog_);
|
||||
else widgets::dialog(c, "THE SERVER'S KEY CHANGED", "Someone in between? Now it is " + shown, *dialog_);
|
||||
} else if (dialog_ && ask_ == Ask::NewKey) {
|
||||
widgets::dialog(c, "A new key?", "Servers that know the old one will ask for a password again.", *dialog_);
|
||||
} else if (dialog_ && ask_ == Ask::Forget) {
|
||||
widgets::dialog(c, "Forget this host?", hosts_[static_cast<size_t>(std::min<int>(list_.selected(), static_cast<int>(hosts_.size()) - 1))], *dialog_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "dialog_model.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/ssh_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "settings.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The SSH App (issue #2, docs/milestones/N1.md): the saved hosts, and a terminal on the one that
|
||||
// is connected. Leaving it with Home doesn't end the session; coming back finds it.
|
||||
class SshApp : public App {
|
||||
public:
|
||||
static constexpr const char* kPublicKeyPath = "/ssh/id_ed25519.pub";
|
||||
|
||||
SshApp(SshService& ssh, Settings& settings, StorageService& storage) : ssh_(ssh), settings_(settings), storage_(storage) {}
|
||||
|
||||
void onEnter() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); }
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
// `ssh user@host` from the Shell or a console: "" or why not.
|
||||
std::string connectTo(const std::string& target);
|
||||
|
||||
private:
|
||||
enum class View { Hosts, Entry, Session, Key };
|
||||
struct Font {
|
||||
const lgfx::IFont* font;
|
||||
int w, h;
|
||||
};
|
||||
static const Font kFonts[5];
|
||||
|
||||
void grid(int& cols, int& rows) const;
|
||||
void connect(const term::SshTarget& target);
|
||||
void setFont(int index);
|
||||
bool sessionKey(const KeyEvent& e);
|
||||
void drawHosts(Canvas& c);
|
||||
void drawSession(Canvas& c);
|
||||
void drawTerminal(Canvas& c);
|
||||
void drawKey(Canvas& c);
|
||||
void say(const std::string& text);
|
||||
void writePublicKey();
|
||||
|
||||
SshService& ssh_;
|
||||
Settings& settings_;
|
||||
StorageService& storage_;
|
||||
View view_ = View::Hosts;
|
||||
ListModel list_{8};
|
||||
std::vector<std::string> hosts_;
|
||||
LineEditor entry_{96}, password_{96};
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
enum class Ask { None, Trust, NewKey, Forget } ask_ = Ask::None;
|
||||
SshService::State shownState_ = SshService::State::Idle;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0, shownRevision_ = 0, lastDrawMs_ = 0;
|
||||
int font_ = 1, scroll_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,133 @@
|
||||
#include "apps/vpn_page.h"
|
||||
|
||||
#include <SD.h>
|
||||
|
||||
#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<KeyHelp>& 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
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<KeyHelp>& 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<DialogModel> confirm_;
|
||||
Ask ask_ = Ask::None;
|
||||
std::string message_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -12,6 +12,10 @@
|
||||
#include "apps/demo_app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "apps/shell_app.h"
|
||||
#include "apps/ssh_app.h"
|
||||
#include "services/net_tools.h"
|
||||
#include "services/ssh_service.h"
|
||||
#include "services/vpn_service.h"
|
||||
#include "services/web_share.h"
|
||||
#include "apps/gemini_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
@@ -27,6 +31,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 +91,10 @@ static WifiService* wifi;
|
||||
static IrcService* irc;
|
||||
static UpdateService* update;
|
||||
static DebugConsole* debugConsole;
|
||||
static VpnService* vpnService; // not in Safe Mode
|
||||
static NetTools netTools; // ping, nslookup and the rest (issue #90)
|
||||
static SshService* sshService; // not in Safe Mode (issue #2)
|
||||
static SshApp* sshApp;
|
||||
static Notifier* notifier;
|
||||
static LauncherApp launcher;
|
||||
static AppManager* apps;
|
||||
@@ -132,6 +141,9 @@ 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.ssh = sshService && sshService->active();
|
||||
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
|
||||
using WifiState = WifiController::State;
|
||||
switch (wifi->state()) {
|
||||
@@ -209,6 +221,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);
|
||||
@@ -219,15 +233,19 @@ void setup() {
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
sshService = new SshService(settings);
|
||||
sshApp = new SshApp(*sshService, settings, *storageService);
|
||||
apps->registerApp({"ssh", "SSH", false, sshApp});
|
||||
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
|
||||
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
|
||||
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
|
||||
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 +685,11 @@ static const char* const kHelp =
|
||||
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
|
||||
"sd card | sd list | cat <path> | log <text> | 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"
|
||||
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
|
||||
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
|
||||
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
|
||||
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\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 +749,66 @@ static void saveScreenshot() {
|
||||
});
|
||||
}
|
||||
|
||||
// `ssh ...` (issue #2, Q265): the session is the SSH App's; this opens it there.
|
||||
static void sshCommand(const String& arg) {
|
||||
if (!sshService) return (void)console.println("ssh: not available in Safe Mode");
|
||||
SshService& s = *sshService;
|
||||
if (arg == "status") {
|
||||
bool live = s.state() != SshService::State::Idle && s.state() != SshService::State::Ended;
|
||||
if (s.state() == SshService::State::Idle) console.println("ssh: no session");
|
||||
else console.printf("ssh: %s%s%s\n", s.target().text().c_str(), live ? ", " : ": ", s.state() == SshService::State::Open ? "open" : s.status().c_str());
|
||||
console.printf("ssh: this device's key: %s\n", s.hasKey() ? s.publicKey().c_str() : "none (the SSH App makes one)");
|
||||
} else if (arg == "stop") {
|
||||
s.disconnect();
|
||||
console.println("ssh: stopped");
|
||||
} else if (arg.indexOf('@') > 0) {
|
||||
std::string why = sshApp->connectTo(arg.c_str());
|
||||
if (!why.empty()) return (void)console.printf("ssh: error %s\n", why.c_str());
|
||||
if (!apps->open("ssh")) return (void)console.println("ssh: connecting, but Setup is running");
|
||||
console.printf("ssh: connecting to %s, in the SSH App\n", s.target().text().c_str());
|
||||
} else {
|
||||
console.println("ssh: user@host[:port] | status | stop");
|
||||
}
|
||||
}
|
||||
|
||||
// `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 +903,10 @@ static void runCommand(String line, bool fromSerial = false) {
|
||||
else console.println("Not now: Setup is running");
|
||||
return;
|
||||
}
|
||||
if (netTools.command(line.c_str())) return;
|
||||
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
|
||||
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status"));
|
||||
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 +1244,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");
|
||||
|
||||
@@ -238,7 +238,10 @@ void IrcService::loop() {
|
||||
retryAtMs_ = now; // reconnect as soon as Wi-Fi is back
|
||||
} else if (!open_) {
|
||||
if (static_cast<int32_t>(now - retryAtMs_) >= 0) {
|
||||
if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
|
||||
// A secure connection peaks at 52 KB. Under an open SSH session there isn't that much, and
|
||||
// trying anyway takes the heap down to nothing (seen on the device, issue #2).
|
||||
if (esp_get_free_heap_size() < kNeedFree) scheduleRetry("not enough memory: close the SSH session");
|
||||
else if (!open()) scheduleRetry("could not connect to " + draftConfig().host);
|
||||
}
|
||||
} else if (!conn_->connected()) {
|
||||
close("");
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace roro {
|
||||
// session is shared with the IRC App under a lock.
|
||||
class IrcService : public Service {
|
||||
public:
|
||||
static constexpr size_t kNeedFree = 60 * 1024; // free memory a connection attempt wants
|
||||
enum class Status { Stopped, WaitingForWifi, Connecting, Registering, Online, Paused, Retrying };
|
||||
|
||||
IrcService(KeyValueStore& store, const std::string& defaultNick, WifiService& wifi, StorageService& storage,
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
#include "services/net_tools.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <NetworkClientSecure.h>
|
||||
|
||||
#include <lwip/etharp.h>
|
||||
#include <lwip/dns.h>
|
||||
#include <lwip/netdb.h>
|
||||
#include <lwip/netif.h>
|
||||
#include <lwip/priv/tcp_priv.h>
|
||||
#include <lwip/sockets.h>
|
||||
#include <lwip/tcpip.h>
|
||||
#include <lwip/udp.h>
|
||||
#include <mbedtls/x509_crt.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <esp_random.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
|
||||
#include "ipv4.h"
|
||||
#include "net_probe.h"
|
||||
#include "platform/ca_roots.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr int kMaxHops = 20;
|
||||
constexpr uint32_t kPingEveryMs = 1000, kPingWaitMs = 1000, kHopWaitMs = 2000, kPortWaitMs = 5000, kDnsWaitMs = 3000;
|
||||
// A TLS handshake peaks at about 52 KB of heap; below this it isn't tried.
|
||||
constexpr size_t kTlsNeedsFree = 70 * 1024;
|
||||
|
||||
struct LwipLock {
|
||||
LwipLock() { LOCK_TCPIP_CORE(); }
|
||||
~LwipLock() { UNLOCK_TCPIP_CORE(); }
|
||||
};
|
||||
|
||||
std::string text(uint32_t networkOrder) { return net::formatIpv4(lwip_ntohl(networkOrder)); }
|
||||
|
||||
// A name or an address, as an address in network order. False: it doesn't resolve.
|
||||
bool resolve(const std::string& host, uint32_t& out) {
|
||||
uint32_t ip;
|
||||
if (net::parseIpv4(host, ip)) {
|
||||
out = lwip_htonl(ip);
|
||||
return true;
|
||||
}
|
||||
struct addrinfo hints = {};
|
||||
hints.ai_family = AF_INET;
|
||||
struct addrinfo* found = nullptr;
|
||||
if (lwip_getaddrinfo(host.c_str(), nullptr, &hints, &found) != 0 || !found) return false;
|
||||
out = reinterpret_cast<struct sockaddr_in*>(found->ai_addr)->sin_addr.s_addr;
|
||||
lwip_freeaddrinfo(found);
|
||||
return true;
|
||||
}
|
||||
|
||||
void waitMs(int socket, uint32_t ms) {
|
||||
struct timeval tv = {static_cast<time_t>(ms / 1000), static_cast<suseconds_t>((ms % 1000) * 1000)};
|
||||
lwip_setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct NetTools::Job {
|
||||
enum class Kind { Ping, Trace, Port, Lookup, Tls, Ntp } kind;
|
||||
NetTools* owner;
|
||||
Console::Origin from;
|
||||
net::PingArgs ping;
|
||||
net::PortArgs port;
|
||||
net::LookupArgs lookup;
|
||||
|
||||
bool stopped() const { return owner->stop_; }
|
||||
// Sends one echo request and waits for what answers it. The time in ms, or -1; `from` and
|
||||
// `kind` say who answered and how.
|
||||
int echo(int socket, uint32_t to, uint16_t id, uint16_t seq, int size, uint32_t waitFor, uint32_t& from, net::IcmpAnswer::Kind& kind);
|
||||
void runPing();
|
||||
void runTrace();
|
||||
void runPort();
|
||||
void runLookup();
|
||||
void runTls();
|
||||
void runNtp();
|
||||
};
|
||||
|
||||
int NetTools::Job::echo(int socket, uint32_t to, uint16_t id, uint16_t seq, int size, uint32_t waitFor, uint32_t& from, net::IcmpAnswer::Kind& kind) {
|
||||
uint8_t packet[8 + 1400], answer[128];
|
||||
size_t len = net::buildEcho(packet, sizeof packet, id, seq, static_cast<size_t>(size));
|
||||
struct sockaddr_in dest = {};
|
||||
dest.sin_family = AF_INET;
|
||||
dest.sin_addr.s_addr = to;
|
||||
uint32_t sent = micros();
|
||||
if (lwip_sendto(socket, packet, len, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) < 0) return -2;
|
||||
while (!stopped()) {
|
||||
uint32_t gone = (micros() - sent) / 1000;
|
||||
if (gone >= waitFor) break;
|
||||
waitMs(socket, std::min<uint32_t>(waitFor - gone, 200)); // short waits: `cancel` is noticed
|
||||
struct sockaddr_in who = {};
|
||||
socklen_t wholen = sizeof who;
|
||||
int n = lwip_recvfrom(socket, answer, sizeof answer, 0, reinterpret_cast<struct sockaddr*>(&who), &wholen);
|
||||
if (n <= 0) continue;
|
||||
net::IcmpAnswer a = net::parseIcmp(answer, static_cast<size_t>(n));
|
||||
if (a.kind == net::IcmpAnswer::Kind::Other || a.id != id || a.seq != seq) continue; // somebody else's
|
||||
from = who.sin_addr.s_addr;
|
||||
kind = a.kind;
|
||||
return static_cast<int>((micros() - sent + 500) / 1000);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void NetTools::Job::runPing() {
|
||||
uint32_t to;
|
||||
if (!resolve(ping.host, to)) return (void)console.printf("ping: %s doesn't resolve\n", ping.host.c_str());
|
||||
int s = lwip_socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
|
||||
if (s < 0) return (void)console.println("ping: error no socket");
|
||||
console.printf("ping: %s, %d bytes\n", text(to).c_str(), ping.size);
|
||||
uint16_t id = static_cast<uint16_t>(esp_random());
|
||||
net::PingStats stats;
|
||||
for (int seq = 1; seq <= ping.count && !stopped(); seq++) {
|
||||
uint32_t started = millis(), from = 0;
|
||||
net::IcmpAnswer::Kind kind = net::IcmpAnswer::Kind::Other;
|
||||
stats.sent++;
|
||||
int ms = echo(s, to, id, static_cast<uint16_t>(seq), ping.size, kPingWaitMs, from, kind);
|
||||
if (ms == -2) console.printf("ping: %d not sent: no route, or too big\n", seq);
|
||||
else if (ms < 0) console.printf("ping: %d no answer\n", seq);
|
||||
else if (kind == net::IcmpAnswer::Kind::Echo) {
|
||||
stats.add(static_cast<uint32_t>(ms));
|
||||
console.printf("ping: %d %d ms\n", seq, ms);
|
||||
} else {
|
||||
console.printf("ping: %d %s says %s\n", seq, text(from).c_str(), kind == net::IcmpAnswer::Kind::TimeExceeded ? "too many hops" : "unreachable");
|
||||
}
|
||||
while (seq < ping.count && !stopped() && millis() - started < kPingEveryMs) delay(50);
|
||||
}
|
||||
lwip_close(s);
|
||||
console.printf("ping: %s%s\n", stopped() ? "stopped, " : "", stats.summary().c_str());
|
||||
}
|
||||
|
||||
// An echo request allowed one hop, then two, then three: each router that drops it says so, and
|
||||
// that is the list.
|
||||
void NetTools::Job::runTrace() {
|
||||
uint32_t to;
|
||||
if (!resolve(ping.host, to)) return (void)console.printf("traceroute: %s doesn't resolve\n", ping.host.c_str());
|
||||
int s = lwip_socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
|
||||
if (s < 0) return (void)console.println("traceroute: error no socket");
|
||||
console.printf("traceroute: to %s, %d hops at most\n", text(to).c_str(), kMaxHops);
|
||||
uint16_t id = static_cast<uint16_t>(esp_random());
|
||||
bool arrived = false;
|
||||
for (int hop = 1; hop <= kMaxHops && !stopped() && !arrived; hop++) {
|
||||
int ttl = hop;
|
||||
lwip_setsockopt(s, IPPROTO_IP, IP_TTL, &ttl, sizeof ttl);
|
||||
uint32_t from = 0;
|
||||
net::IcmpAnswer::Kind kind = net::IcmpAnswer::Kind::Other;
|
||||
int ms = echo(s, to, id, static_cast<uint16_t>(hop), 32, kHopWaitMs, from, kind);
|
||||
if (ms < 0) console.printf("traceroute: %2d *\n", hop);
|
||||
else console.printf("traceroute: %2d %s %d ms%s\n", hop, text(from).c_str(), ms, kind == net::IcmpAnswer::Kind::Unreachable ? " unreachable" : "");
|
||||
arrived = ms >= 0 && kind != net::IcmpAnswer::Kind::TimeExceeded;
|
||||
}
|
||||
lwip_close(s);
|
||||
console.printf("traceroute: %s\n", stopped() ? "stopped" : arrived ? "arrived" : "not reached");
|
||||
}
|
||||
|
||||
void NetTools::Job::runPort() {
|
||||
uint32_t to;
|
||||
if (!resolve(port.host, to)) return (void)console.printf("port: %s doesn't resolve\n", port.host.c_str());
|
||||
int s = lwip_socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (s < 0) return (void)console.println("port: error no socket");
|
||||
lwip_fcntl(s, F_SETFL, lwip_fcntl(s, F_GETFL, 0) | O_NONBLOCK);
|
||||
struct sockaddr_in dest = {};
|
||||
dest.sin_family = AF_INET;
|
||||
dest.sin_port = lwip_htons(port.port);
|
||||
dest.sin_addr.s_addr = to;
|
||||
uint32_t started = millis();
|
||||
int error = lwip_connect(s, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) == 0 ? 0 : errno;
|
||||
bool answered = error == 0;
|
||||
while (error == EINPROGRESS && !answered && !stopped() && millis() - started < kPortWaitMs) {
|
||||
fd_set writable, failed;
|
||||
FD_ZERO(&writable);
|
||||
FD_ZERO(&failed);
|
||||
FD_SET(s, &writable);
|
||||
FD_SET(s, &failed);
|
||||
struct timeval tv = {0, 200000};
|
||||
if (lwip_select(s + 1, nullptr, &writable, &failed, &tv) > 0) {
|
||||
socklen_t len = sizeof error;
|
||||
lwip_getsockopt(s, SOL_SOCKET, SO_ERROR, &error, &len);
|
||||
answered = true;
|
||||
}
|
||||
}
|
||||
uint32_t ms = millis() - started;
|
||||
lwip_close(s);
|
||||
std::string where = text(to) + ":" + std::to_string(port.port);
|
||||
if (answered && error == 0) console.printf("port: %s open, %lu ms\n", where.c_str(), (unsigned long)ms);
|
||||
else if (answered && (error == ECONNREFUSED || error == ECONNRESET)) console.printf("port: %s refused, %lu ms: the host is there, nothing listens\n", where.c_str(), (unsigned long)ms);
|
||||
else if (answered || error != EINPROGRESS) console.printf("port: %s no route to it (error %d)\n", where.c_str(), error);
|
||||
else if (stopped()) console.println("port: stopped");
|
||||
else console.printf("port: %s no answer in %lu s: down, or filtered\n", where.c_str(), (unsigned long)(kPortWaitMs / 1000));
|
||||
}
|
||||
|
||||
// Asks one server directly, so that the answer says which server and how long, which the
|
||||
// system's own resolver doesn't.
|
||||
void NetTools::Job::runLookup() {
|
||||
uint32_t server = 0;
|
||||
if (!lookup.server.empty()) resolve(lookup.server, server);
|
||||
else {
|
||||
LwipLock lock;
|
||||
const ip_addr_t* first = dns_getserver(0);
|
||||
if (first && IP_IS_V4(first)) server = ip_2_ip4(first)->addr;
|
||||
}
|
||||
if (!server) return (void)console.println("nslookup: no DNS server is set");
|
||||
int s = lwip_socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (s < 0) return (void)console.println("nslookup: error no socket");
|
||||
uint8_t query[300], answer[512];
|
||||
uint16_t id = static_cast<uint16_t>(esp_random());
|
||||
size_t len = net::buildDnsQuery(query, sizeof query, id, lookup.name);
|
||||
struct sockaddr_in dest = {};
|
||||
dest.sin_family = AF_INET;
|
||||
dest.sin_port = lwip_htons(53);
|
||||
dest.sin_addr.s_addr = server;
|
||||
uint32_t started = millis();
|
||||
net::DnsAnswer result;
|
||||
bool got = false;
|
||||
if (len && lwip_sendto(s, query, len, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) >= 0) {
|
||||
while (!got && !stopped() && millis() - started < kDnsWaitMs) {
|
||||
waitMs(s, 200);
|
||||
int n = lwip_recv(s, answer, sizeof answer, 0);
|
||||
if (n > 0) got = net::parseDnsAnswer(answer, static_cast<size_t>(n), id, result);
|
||||
}
|
||||
}
|
||||
uint32_t ms = millis() - started;
|
||||
lwip_close(s);
|
||||
if (!got) return (void)console.printf("nslookup: no answer from %s in %lu s\n", text(server).c_str(), (unsigned long)(kDnsWaitMs / 1000));
|
||||
console.printf("nslookup: %s answered in %lu ms\n", text(server).c_str(), (unsigned long)ms);
|
||||
if (!result.alias.empty()) console.printf("nslookup: %s is %s\n", lookup.name.c_str(), result.alias.c_str());
|
||||
for (uint32_t ip : result.addresses) console.printf("nslookup: %s\n", net::formatIpv4(ip).c_str());
|
||||
if (result.rcode == 3) console.printf("nslookup: there is no %s\n", lookup.name.c_str());
|
||||
else if (result.rcode) console.printf("nslookup: the server refused (code %d)\n", result.rcode);
|
||||
else if (result.addresses.empty()) console.printf("nslookup: %s has no IPv4 address%s\n", lookup.name.c_str(), result.truncated ? " in a first packet" : "");
|
||||
}
|
||||
|
||||
// A handshake that checks nothing, to see the certificate whatever it is; then the certificate is
|
||||
// checked here, against this device's own roots and the name asked for, and the answer is said in
|
||||
// words. It is what the Update Service's connection would have decided.
|
||||
void NetTools::Job::runTls() {
|
||||
if (ESP.getFreeHeap() < kTlsNeedsFree)
|
||||
return (void)console.printf("tls: not enough memory (%u KB free, %u needed): close IRC or a Gemini page\n", (unsigned)(ESP.getFreeHeap() / 1024),
|
||||
(unsigned)(kTlsNeedsFree / 1024));
|
||||
NetworkClientSecure tls;
|
||||
tls.setInsecure();
|
||||
uint32_t started = millis();
|
||||
if (!tls.connect(port.host.c_str(), port.port, 8000)) {
|
||||
char why[100] = "";
|
||||
tls.lastError(why, sizeof why);
|
||||
return (void)console.printf("tls: no handshake with %s:%u in %lu ms: %s\n", port.host.c_str(), (unsigned)port.port, (unsigned long)(millis() - started),
|
||||
why[0] ? why : "no connection");
|
||||
}
|
||||
console.printf("tls: %s:%u answered in %lu ms\n", port.host.c_str(), (unsigned)port.port, (unsigned long)(millis() - started));
|
||||
const mbedtls_x509_crt* cert = tls.getPeerCertificate();
|
||||
if (!cert) {
|
||||
tls.stop();
|
||||
return (void)console.println("tls: it showed no certificate");
|
||||
}
|
||||
char dn[200];
|
||||
std::string subject = mbedtls_x509_dn_gets(dn, sizeof dn, &cert->subject) > 0 ? net::certName(dn) : "?";
|
||||
std::string issuer = mbedtls_x509_dn_gets(dn, sizeof dn, &cert->issuer) > 0 ? net::certName(dn) : "?";
|
||||
console.printf("tls: for %s, by %s\n", subject.c_str(), issuer.c_str());
|
||||
const mbedtls_x509_time& from = cert->valid_from;
|
||||
const mbedtls_x509_time& to = cert->valid_to;
|
||||
time_t now = time(nullptr);
|
||||
struct tm today;
|
||||
gmtime_r(&now, &today);
|
||||
bool clock = today.tm_year + 1900 >= 2024;
|
||||
int left = net::daysBetween(today.tm_year + 1900, today.tm_mon + 1, today.tm_mday, to.year, to.mon, to.day);
|
||||
console.printf("tls: valid %04d-%02d-%02d to %04d-%02d-%02d", from.year, from.mon, from.day, to.year, to.mon, to.day);
|
||||
if (!clock) console.println(" (this clock isn't set)");
|
||||
else if (left >= 0) console.printf(", %d days left\n", left);
|
||||
else console.printf(", EXPIRED %d days ago\n", -left);
|
||||
|
||||
mbedtls_x509_crt roots;
|
||||
mbedtls_x509_crt_init(&roots);
|
||||
uint32_t flags = 0;
|
||||
bool parsed = mbedtls_x509_crt_parse(&roots, reinterpret_cast<const unsigned char*>(kTrustedRootsPem), sizeof kTrustedRootsPem) == 0;
|
||||
int verdict = parsed ? mbedtls_x509_crt_verify(const_cast<mbedtls_x509_crt*>(cert), &roots, nullptr, port.host.c_str(), &flags, nullptr, nullptr) : -1;
|
||||
mbedtls_x509_crt_free(&roots);
|
||||
if (verdict == 0) console.println("tls: this device trusts it");
|
||||
else {
|
||||
std::string why;
|
||||
if ((flags & MBEDTLS_X509_BADCERT_EXPIRED) || (clock && left < 0)) why += ", expired";
|
||||
if (flags & MBEDTLS_X509_BADCERT_FUTURE) why += ", not valid yet";
|
||||
if (flags & MBEDTLS_X509_BADCERT_CN_MISMATCH) why += ", not for that name";
|
||||
if (flags & MBEDTLS_X509_BADCERT_NOT_TRUSTED) why += ", not signed by a root this device has";
|
||||
if (why.empty()) why = ", it doesn't check out";
|
||||
console.printf("tls: NOT trusted here: %s\n", why.c_str() + 2);
|
||||
}
|
||||
uint8_t sha[32];
|
||||
if (tls.getFingerprintSHA256(sha)) {
|
||||
char hex[65];
|
||||
for (int i = 0; i < 32; i++) std::snprintf(hex + i * 2, 3, "%02x", sha[i]);
|
||||
console.printf("tls: sha256 %s\n", hex);
|
||||
}
|
||||
tls.stop();
|
||||
}
|
||||
|
||||
// Asks a time server and compares with this device's clock, allowing for half the round trip.
|
||||
void NetTools::Job::runNtp() {
|
||||
uint32_t to;
|
||||
if (!resolve(port.host, to)) return (void)console.printf("ntp: %s doesn't resolve\n", port.host.c_str());
|
||||
int s = lwip_socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (s < 0) return (void)console.println("ntp: error no socket");
|
||||
uint8_t packet[net::kNtpPacket];
|
||||
net::buildNtpRequest(packet);
|
||||
struct sockaddr_in dest = {};
|
||||
dest.sin_family = AF_INET;
|
||||
dest.sin_port = lwip_htons(123);
|
||||
dest.sin_addr.s_addr = to;
|
||||
uint32_t sent = micros();
|
||||
net::NtpAnswer answer;
|
||||
bool got = false;
|
||||
struct timeval own = {};
|
||||
if (lwip_sendto(s, packet, sizeof packet, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) >= 0) {
|
||||
while (!got && !stopped() && (micros() - sent) / 1000 < kDnsWaitMs) {
|
||||
waitMs(s, 200);
|
||||
int n = lwip_recv(s, packet, sizeof packet, 0);
|
||||
if (n <= 0) continue;
|
||||
gettimeofday(&own, nullptr);
|
||||
got = net::parseNtpAnswer(packet, static_cast<size_t>(n), answer);
|
||||
}
|
||||
}
|
||||
uint32_t tripMs = (micros() - sent) / 1000;
|
||||
lwip_close(s);
|
||||
if (!got) return (void)console.printf("ntp: no answer from %s (%s) in %lu s\n", port.host.c_str(), text(to).c_str(), (unsigned long)(kDnsWaitMs / 1000));
|
||||
console.printf("ntp: %s (%s), stratum %d, %lu ms away\n", port.host.c_str(), text(to).c_str(), answer.stratum, (unsigned long)tripMs);
|
||||
int64_t ownMs = static_cast<int64_t>(own.tv_sec) * 1000 + own.tv_usec / 1000, serverMs = answer.seconds * 1000 + answer.millis + tripMs / 2;
|
||||
if (own.tv_sec < 1700000000) console.println("ntp: this clock isn't set");
|
||||
else console.printf("ntp: this clock is %s\n", net::clockOffset(ownMs, serverMs).c_str());
|
||||
}
|
||||
|
||||
void NetTools::task(void* arg) {
|
||||
Job* job = static_cast<Job*>(arg);
|
||||
{
|
||||
Console::As as(job->from); // the lines go to the console that asked (the Shell shows only its own)
|
||||
switch (job->kind) {
|
||||
case Job::Kind::Ping: job->runPing(); break;
|
||||
case Job::Kind::Trace: job->runTrace(); break;
|
||||
case Job::Kind::Port: job->runPort(); break;
|
||||
case Job::Kind::Lookup: job->runLookup(); break;
|
||||
case Job::Kind::Tls: job->runTls(); break;
|
||||
case Job::Kind::Ntp: job->runNtp(); break;
|
||||
}
|
||||
}
|
||||
NetTools* owner = job->owner;
|
||||
delete job;
|
||||
owner->busy_ = false;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void NetTools::start(Job* job) {
|
||||
job->owner = this;
|
||||
job->from = console.origin();
|
||||
stop_ = false;
|
||||
busy_ = true;
|
||||
// A TLS handshake needs far more stack than a ping.
|
||||
if (xTaskCreate(task, "nettool", job->kind == Job::Kind::Tls ? 12288 : 6144, job, 1, nullptr) != pdPASS) {
|
||||
busy_ = false;
|
||||
delete job;
|
||||
console.println("net: error not enough memory for it");
|
||||
}
|
||||
}
|
||||
|
||||
bool NetTools::command(const std::string& line) {
|
||||
size_t space = line.find(' ');
|
||||
std::string name = line.substr(0, space), args = space == std::string::npos ? "" : line.substr(space + 1);
|
||||
if (name == "ifconfig") {
|
||||
LwipLock lock;
|
||||
for (struct netif* n = netif_list; n; n = n->next) {
|
||||
if (n->name[0] == 'l' && n->name[1] == 'o') continue;
|
||||
const char* label = n->name[0] == 's' && n->name[1] == 't' ? "wifi" : n->name[0] == 'w' && n->name[1] == 'g' ? "vpn" : nullptr;
|
||||
char raw[4] = {n->name[0], n->name[1], static_cast<char>('0' + n->num % 10), 0};
|
||||
bool up = netif_is_up(n) && netif_is_link_up(n);
|
||||
console.printf("ifconfig: %s %s/%d", label ? label : raw, text(netif_ip4_addr(n)->addr).c_str(), __builtin_popcount(netif_ip4_netmask(n)->addr));
|
||||
if (netif_ip4_gw(n)->addr) console.printf(" gw %s", text(netif_ip4_gw(n)->addr).c_str());
|
||||
console.printf(" mtu %u, %s%s\n", (unsigned)n->mtu, up ? "up" : "down", n == netif_default ? ", default route" : "");
|
||||
}
|
||||
std::string servers;
|
||||
for (u8_t i = 0; i < DNS_MAX_SERVERS; i++) {
|
||||
const ip_addr_t* d = dns_getserver(i);
|
||||
if (d && IP_IS_V4(d) && ip_2_ip4(d)->addr) servers += " " + text(ip_2_ip4(d)->addr);
|
||||
}
|
||||
console.printf("ifconfig: dns%s\n", servers.empty() ? " none" : servers.c_str());
|
||||
return true;
|
||||
}
|
||||
if (name == "arp") {
|
||||
LwipLock lock;
|
||||
int found = 0;
|
||||
for (size_t i = 0; i < ARP_TABLE_SIZE; i++) {
|
||||
ip4_addr_t* ip;
|
||||
struct netif* nif;
|
||||
struct eth_addr* mac;
|
||||
if (!etharp_get_entry(i, &ip, &nif, &mac)) continue;
|
||||
found++;
|
||||
console.printf("arp: %-15s %02x:%02x:%02x:%02x:%02x:%02x\n", text(ip->addr).c_str(), mac->addr[0], mac->addr[1], mac->addr[2], mac->addr[3], mac->addr[4],
|
||||
mac->addr[5]);
|
||||
}
|
||||
if (!found) console.println("arp: nobody heard yet on this network");
|
||||
return true;
|
||||
}
|
||||
if (name == "netstat") {
|
||||
LwipLock lock;
|
||||
for (struct tcp_pcb_listen* p = tcp_listen_pcbs.listen_pcbs; p; p = p->next) {
|
||||
const char* label = net::portLabel(p->local_port, true);
|
||||
console.printf("netstat: tcp %u listens%s%s%s\n", (unsigned)p->local_port, *label ? " (" : "", label, *label ? ")" : "");
|
||||
}
|
||||
for (struct tcp_pcb* p = tcp_active_pcbs; p; p = p->next) {
|
||||
std::string local = IP_IS_V4(&p->local_ip) ? text(ip_2_ip4(&p->local_ip)->addr) : "::", remote = IP_IS_V4(&p->remote_ip) ? text(ip_2_ip4(&p->remote_ip)->addr) : "::";
|
||||
console.printf("netstat: tcp %s:%u - %s:%u\n", local.c_str(), (unsigned)p->local_port, remote.c_str(), (unsigned)p->remote_port);
|
||||
}
|
||||
for (struct udp_pcb* p = udp_pcbs; p; p = p->next) {
|
||||
const char* label = net::portLabel(p->local_port, false);
|
||||
console.printf("netstat: udp %u%s%s%s\n", (unsigned)p->local_port, *label ? " (" : "", label, *label ? ")" : "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (name != "ping" && name != "traceroute" && name != "port" && name != "nslookup" && name != "tls" && name != "ntp") return false;
|
||||
std::unique_ptr<Job> job(new Job());
|
||||
std::string why;
|
||||
if (name == "ping") {
|
||||
job->kind = Job::Kind::Ping;
|
||||
why = net::parsePing(args, job->ping);
|
||||
} else if (name == "traceroute") {
|
||||
job->kind = Job::Kind::Trace;
|
||||
why = net::parsePing(args, job->ping);
|
||||
if (!why.empty() || args.find(' ') != std::string::npos) why = "traceroute <host>";
|
||||
} else if (name == "port") {
|
||||
job->kind = Job::Kind::Port;
|
||||
why = net::parsePort(args, job->port);
|
||||
} else if (name == "tls") { // the port may be left out: 443
|
||||
job->kind = Job::Kind::Tls;
|
||||
bool onlyHost = !args.empty() && args.find(' ') == std::string::npos && args.find(':') == std::string::npos;
|
||||
why = net::parsePort(onlyHost ? args + " 443" : args, job->port);
|
||||
if (!why.empty() && why.find("port <") == 0) why = "tls <host> [port]";
|
||||
} else if (name == "ntp") { // the server may be left out: the first one in use
|
||||
job->kind = Job::Kind::Ntp;
|
||||
job->port.host = !args.empty() ? args : ntpServer ? ntpServer() : "";
|
||||
if (job->port.host.empty() || !net::validHost(job->port.host)) why = "ntp [server]";
|
||||
} else {
|
||||
job->kind = Job::Kind::Lookup;
|
||||
why = net::parseLookup(args, job->lookup);
|
||||
}
|
||||
if (!why.empty()) return console.printf("%s: %s\n", name.c_str(), why.c_str()), true;
|
||||
if (busy_) return console.printf("%s: another one is running: `cancel` stops it\n", name.c_str()), true;
|
||||
start(job.release());
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "platform/console.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The network troubleshooting commands (issue #90): ping, nslookup, port, traceroute, tls, ntp,
|
||||
// and ifconfig, arp, netstat. The first six take time, so each runs on a task of its own and
|
||||
// prints its lines as they come, to the console that asked; one at a time, and `cancel` stops it.
|
||||
// The other three answer at once. The packets and their meaning are lib/net/src/net_probe.h, which is host-tested.
|
||||
class NetTools {
|
||||
public:
|
||||
// True if `line` was one of its commands (answered, started, or refused with a reason).
|
||||
bool command(const std::string& line);
|
||||
bool busy() const { return busy_; }
|
||||
void cancel() { stop_ = true; }
|
||||
// The time server `ntp` asks when none is named: the first one in Settings.
|
||||
std::function<std::string()> ntpServer;
|
||||
|
||||
private:
|
||||
struct Job;
|
||||
void start(Job* job);
|
||||
static void task(void* arg);
|
||||
|
||||
std::atomic<bool> busy_{false}, stop_{false};
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,365 @@
|
||||
#include "services/ssh_service.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <libssh_esp32.h>
|
||||
#include <libssh/libssh.h>
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kStack = 20480; // a session used 13 KB of it at most (measured)
|
||||
constexpr int kPasswordTries = 3;
|
||||
|
||||
struct Locked {
|
||||
explicit Locked(void* lock) : lock_(static_cast<SemaphoreHandle_t>(lock)) { xSemaphoreTake(lock_, portMAX_DELAY); }
|
||||
~Locked() { xSemaphoreGive(lock_); }
|
||||
SemaphoreHandle_t lock_;
|
||||
};
|
||||
|
||||
std::string fingerprintOf(ssh_session s) {
|
||||
ssh_key key = nullptr;
|
||||
unsigned char* hash = nullptr;
|
||||
size_t len = 0;
|
||||
std::string out;
|
||||
if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) {
|
||||
if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) {
|
||||
out = text; // "SHA256:..."
|
||||
ssh_string_free_char(text);
|
||||
}
|
||||
ssh_clean_pubkey_hash(&hash);
|
||||
}
|
||||
if (key) ssh_key_free(key);
|
||||
return out;
|
||||
}
|
||||
|
||||
void startLibrary() {
|
||||
static bool started = false;
|
||||
if (!started) libssh_begin();
|
||||
started = true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
struct SshService::Run {
|
||||
SshService* service;
|
||||
std::string host, user, privateKey, known;
|
||||
int port, cols, rows;
|
||||
};
|
||||
|
||||
SshService::SshService(Settings& settings) : settings_(settings), lock_(xSemaphoreCreateMutex()) {}
|
||||
|
||||
std::string SshService::status() const {
|
||||
Locked l(lock_);
|
||||
return status_;
|
||||
}
|
||||
std::string SshService::fingerprint() const {
|
||||
Locked l(lock_);
|
||||
return fingerprint_;
|
||||
}
|
||||
std::string SshService::remembered() const {
|
||||
Locked l(lock_);
|
||||
return remembered_;
|
||||
}
|
||||
|
||||
void SshService::say(const std::string& what) {
|
||||
Locked l(lock_);
|
||||
status_ = what;
|
||||
revision_++;
|
||||
}
|
||||
|
||||
void SshService::end(const std::string& why) {
|
||||
{
|
||||
Locked l(lock_);
|
||||
status_ = why;
|
||||
password_.assign(password_.size(), '\0');
|
||||
password_.clear();
|
||||
outgoing_.clear();
|
||||
}
|
||||
state_ = State::Ended;
|
||||
revision_++;
|
||||
}
|
||||
|
||||
std::string SshService::connect(const term::SshTarget& target, int cols, int rows) {
|
||||
if (running_) return "A session is still closing: a moment";
|
||||
if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page";
|
||||
target_ = target;
|
||||
{
|
||||
Locked l(lock_);
|
||||
term_.reset(new term::Terminal(cols, rows, kHistory));
|
||||
term_->reply = [this](const std::string& s) { outgoing_ += s; }; // called with the lock held, from feed()
|
||||
status_ = "Connecting to " + target.host;
|
||||
fingerprint_.clear();
|
||||
remembered_.clear();
|
||||
outgoing_.clear();
|
||||
resized_ = false;
|
||||
}
|
||||
stop_ = answered_ = trusted_ = opened_ = false;
|
||||
auto* run = new Run{this, target.host, target.user, settings_.getString(Setting::SshKey),
|
||||
term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(target.hostPort()), target.port, cols, rows};
|
||||
state_ = State::Connecting;
|
||||
running_ = true;
|
||||
if (xTaskCreate(task, "ssh", kStack, run, 1, nullptr) != pdPASS) {
|
||||
running_ = false;
|
||||
delete run;
|
||||
end("Not enough memory for the session");
|
||||
return "Not enough memory for the session";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
void SshService::clear() {
|
||||
if (state_ != State::Ended || running_) return;
|
||||
Locked l(lock_);
|
||||
term_.reset();
|
||||
status_.clear();
|
||||
state_ = State::Idle;
|
||||
}
|
||||
|
||||
void SshService::disconnect() {
|
||||
stop_ = true;
|
||||
answered_ = true; // whatever it was waiting for
|
||||
}
|
||||
|
||||
void SshService::answerTrust(bool yes) {
|
||||
if (state_ != State::AskTrust) return;
|
||||
if (yes) { // remembered from here on; on the main loop, where settings are written
|
||||
term::SshKnownHosts known(settings_.getString(Setting::SshKnown));
|
||||
known.remember(target_.hostPort(), fingerprint());
|
||||
settings_.setString(Setting::SshKnown, known.stored());
|
||||
}
|
||||
trusted_ = yes;
|
||||
answered_ = true;
|
||||
}
|
||||
|
||||
void SshService::answerPassword(const std::string& password) {
|
||||
if (state_ != State::AskPassword) return;
|
||||
{
|
||||
Locked l(lock_);
|
||||
password_ = password;
|
||||
}
|
||||
answered_ = true;
|
||||
}
|
||||
|
||||
void SshService::withTerminal(const std::function<void(term::Terminal&)>& use) {
|
||||
Locked l(lock_);
|
||||
if (term_) use(*term_);
|
||||
}
|
||||
|
||||
void SshService::send(const std::string& bytes) {
|
||||
if (state_ != State::Open) return;
|
||||
Locked l(lock_);
|
||||
if (outgoing_.size() < 4096) outgoing_ += bytes;
|
||||
}
|
||||
|
||||
void SshService::resize(int cols, int rows) {
|
||||
Locked l(lock_);
|
||||
if (term_) term_->resize(cols, rows);
|
||||
wantCols_ = cols;
|
||||
wantRows_ = rows;
|
||||
resized_ = true;
|
||||
revision_++;
|
||||
}
|
||||
|
||||
void SshService::task(void* arg) {
|
||||
std::unique_ptr<Run> run(static_cast<Run*>(arg));
|
||||
SshService* self = run->service;
|
||||
self->session(*run);
|
||||
run.reset();
|
||||
self->running_ = false;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void SshService::session(Run& run) {
|
||||
startLibrary();
|
||||
ssh_session s = ssh_new();
|
||||
if (!s) return end("Not enough memory for the session");
|
||||
int verbosity = SSH_LOG_NOLOG;
|
||||
long timeout = 10;
|
||||
ssh_options_set(s, SSH_OPTIONS_HOST, run.host.c_str());
|
||||
ssh_options_set(s, SSH_OPTIONS_PORT, &run.port);
|
||||
ssh_options_set(s, SSH_OPTIONS_USER, run.user.c_str());
|
||||
ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout);
|
||||
ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
|
||||
auto fail = [&](const std::string& what) {
|
||||
std::string why = what;
|
||||
const char* detail = ssh_get_error(s);
|
||||
if (detail && *detail && what.back() == ':') {
|
||||
// lwIP reports a refused connection as one reset by the peer.
|
||||
std::string d = detail;
|
||||
if (d.find("reset by peer") != std::string::npos || d.find("refused") != std::string::npos) why = "Nothing listens there: the connection was refused";
|
||||
else if (d.find("imeout") != std::string::npos || d.find("timed out") != std::string::npos) why = "No answer from " + run.host;
|
||||
else why += " " + d;
|
||||
}
|
||||
ssh_disconnect(s);
|
||||
ssh_free(s);
|
||||
end(why);
|
||||
};
|
||||
auto waitForAnswer = [&]() {
|
||||
while (!answered_ && !stop_) vTaskDelay(pdMS_TO_TICKS(50));
|
||||
answered_ = false;
|
||||
return !stop_;
|
||||
};
|
||||
|
||||
if (ssh_connect(s) != SSH_OK) return fail("No connection:");
|
||||
if (stop_) return fail("Stopped");
|
||||
|
||||
// Is it the server it was last time? The first time, and when it has changed, the user decides.
|
||||
std::string seen = fingerprintOf(s);
|
||||
if (seen.empty()) return fail("The server showed no key");
|
||||
if (seen != run.known) {
|
||||
{
|
||||
Locked l(lock_);
|
||||
fingerprint_ = seen;
|
||||
remembered_ = run.known;
|
||||
}
|
||||
say("Is this the right server?");
|
||||
state_ = State::AskTrust;
|
||||
revision_++;
|
||||
if (!waitForAnswer() || !trusted_) return fail(stop_ ? "Stopped" : "Not trusted: not connected");
|
||||
state_ = State::Connecting;
|
||||
}
|
||||
say("Logging in as " + run.user);
|
||||
|
||||
// This device's key first, if it has one and the server takes keys; then a password.
|
||||
int rc = ssh_userauth_none(s, nullptr);
|
||||
int methods = ssh_userauth_list(s, nullptr);
|
||||
if (rc != SSH_AUTH_SUCCESS && !run.privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) {
|
||||
ssh_key key = nullptr;
|
||||
if (ssh_pki_import_privkey_base64(run.privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) {
|
||||
rc = ssh_userauth_publickey(s, nullptr, key);
|
||||
ssh_key_free(key);
|
||||
}
|
||||
}
|
||||
run.privateKey.assign(run.privateKey.size(), '\0');
|
||||
for (int tries = 0; rc != SSH_AUTH_SUCCESS && tries < kPasswordTries; tries++) {
|
||||
if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) return fail("The server takes neither this key nor a password");
|
||||
say(tries ? "Wrong password" : "");
|
||||
state_ = State::AskPassword;
|
||||
revision_++;
|
||||
if (!waitForAnswer()) return fail("Stopped");
|
||||
state_ = State::Connecting;
|
||||
say("Logging in as " + run.user);
|
||||
std::string password;
|
||||
{
|
||||
Locked l(lock_);
|
||||
password.swap(password_);
|
||||
}
|
||||
if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str());
|
||||
else { // asked as questions: every one that hides its answer gets the password
|
||||
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
|
||||
for (int round = 0; rc == SSH_AUTH_INFO && round < 4; round++) {
|
||||
int n = ssh_userauth_kbdint_getnprompts(s);
|
||||
for (int i = 0; i < n; i++) ssh_userauth_kbdint_setanswer(s, static_cast<unsigned>(i), password.c_str());
|
||||
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
|
||||
}
|
||||
}
|
||||
password.assign(password.size(), '\0');
|
||||
if (rc == SSH_AUTH_ERROR) return fail("Login failed:");
|
||||
}
|
||||
if (rc != SSH_AUTH_SUCCESS) return fail("Wrong password, three times");
|
||||
|
||||
ssh_channel ch = ssh_channel_new(s);
|
||||
int cols, rows;
|
||||
{
|
||||
Locked l(lock_);
|
||||
cols = resized_ ? wantCols_ : run.cols;
|
||||
rows = resized_ ? wantRows_ : run.rows;
|
||||
resized_ = false;
|
||||
}
|
||||
if (!ch || ssh_channel_open_session(ch) != SSH_OK || ssh_channel_request_pty_size(ch, "xterm", cols, rows) != SSH_OK ||
|
||||
ssh_channel_request_shell(ch) != SSH_OK) {
|
||||
if (ch) ssh_channel_free(ch);
|
||||
return fail("No shell:");
|
||||
}
|
||||
say("");
|
||||
opened_ = true;
|
||||
state_ = State::Open;
|
||||
revision_++;
|
||||
|
||||
std::string why = "The session ended";
|
||||
uint8_t buf[512];
|
||||
while (!stop_) {
|
||||
int waiting = ssh_channel_poll_timeout(ch, 20, 0); // also where it sleeps when nothing happens
|
||||
if (waiting == SSH_ERROR || waiting == SSH_EOF) break;
|
||||
bool busy = false;
|
||||
for (int std_err = 0; std_err < 2; std_err++) {
|
||||
int n = ssh_channel_read_nonblocking(ch, buf, sizeof buf, std_err);
|
||||
if (n > 0) {
|
||||
Locked l(lock_);
|
||||
term_->feed(buf, static_cast<size_t>(n));
|
||||
revision_++;
|
||||
busy = true;
|
||||
}
|
||||
}
|
||||
std::string out;
|
||||
int newCols = 0, newRows = 0;
|
||||
{
|
||||
Locked l(lock_);
|
||||
out.swap(outgoing_);
|
||||
if (resized_) {
|
||||
newCols = wantCols_;
|
||||
newRows = wantRows_;
|
||||
resized_ = false;
|
||||
}
|
||||
}
|
||||
if (newCols) ssh_channel_change_pty_size(ch, newCols, newRows);
|
||||
if (!out.empty() && ssh_channel_write(ch, out.data(), static_cast<uint32_t>(out.size())) < 0) {
|
||||
why = "The connection was lost";
|
||||
break;
|
||||
}
|
||||
if (ssh_channel_is_eof(ch) || !ssh_channel_is_open(ch)) break;
|
||||
if (!ssh_is_connected(s)) {
|
||||
why = "The connection was lost";
|
||||
break;
|
||||
}
|
||||
if (!busy && out.empty()) vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
if (stop_) why = "Disconnected";
|
||||
ssh_channel_close(ch);
|
||||
ssh_channel_free(ch);
|
||||
ssh_disconnect(s);
|
||||
ssh_free(s);
|
||||
end(why);
|
||||
}
|
||||
|
||||
// On a task of its own for its stack; the main loop waits the moment it takes.
|
||||
std::string SshService::makeKey() {
|
||||
struct Made {
|
||||
std::string priv, pub, why;
|
||||
std::atomic<bool> done{false};
|
||||
};
|
||||
auto made = std::make_shared<Made>();
|
||||
auto* arg = new std::shared_ptr<Made>(made);
|
||||
auto work = [](void* p) {
|
||||
std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p);
|
||||
delete static_cast<std::shared_ptr<Made>*>(p);
|
||||
startLibrary();
|
||||
ssh_key key = nullptr, pub = nullptr;
|
||||
char *b64 = nullptr, *pub64 = nullptr;
|
||||
if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made";
|
||||
else if (ssh_pki_export_privkey_base64(key, nullptr, nullptr, nullptr, &b64) != SSH_OK || ssh_pki_export_privkey_to_pubkey(key, &pub) != SSH_OK ||
|
||||
ssh_pki_export_pubkey_base64(pub, &pub64) != SSH_OK)
|
||||
m->why = "The key couldn't be written out";
|
||||
else {
|
||||
m->priv = b64;
|
||||
m->pub = std::string("ssh-ed25519 ") + pub64 + " roro9stack";
|
||||
}
|
||||
if (b64) ssh_string_free_char(b64);
|
||||
if (pub64) ssh_string_free_char(pub64);
|
||||
if (pub) ssh_key_free(pub);
|
||||
if (key) ssh_key_free(key);
|
||||
m->done = true;
|
||||
vTaskDelete(nullptr);
|
||||
};
|
||||
if (xTaskCreate(work, "sshkey", 16384, arg, 1, nullptr) != pdPASS) {
|
||||
delete arg;
|
||||
return "Not enough memory to make a key";
|
||||
}
|
||||
for (int waited = 0; !made->done && waited < 10000; waited += 20) delay(20);
|
||||
if (!made->done) return "Making the key took too long";
|
||||
if (!made->why.empty()) return made->why;
|
||||
if (!settings_.setString(Setting::SshKey, made->priv) || !settings_.setString(Setting::SshPublic, made->pub)) return "The key couldn't be stored";
|
||||
return "";
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "event_bus.h"
|
||||
#include "settings.h"
|
||||
#include "ssh_hosts.h"
|
||||
#include "terminal.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// One SSH session to a shell (issue #2, docs/milestones/N1.md): the protocol is libssh's, on a
|
||||
// task of its own; what it prints goes into a term::Terminal, which the SSH App draws. The
|
||||
// session lasts until the far end closes it or disconnect() is called, whether the App is in
|
||||
// front or not.
|
||||
//
|
||||
// The task and the main loop share the terminal, the bytes waiting to be sent and the state,
|
||||
// under one lock. Questions for the user (is this the right server? what is the password?) are
|
||||
// states the task waits in until the App answers.
|
||||
class SshService {
|
||||
public:
|
||||
enum class State { Idle, Connecting, AskTrust, AskPassword, Open, Ended };
|
||||
static constexpr size_t kNeedFree = 75 * 1024; // a session peaks at about 63 KB (measured)
|
||||
static constexpr int kHistory = 100;
|
||||
|
||||
explicit SshService(Settings& settings);
|
||||
|
||||
// "" when the connection is on its way, or why not.
|
||||
std::string connect(const term::SshTarget& target, int cols, int rows);
|
||||
void disconnect(); // from any state; the terminal stays to be read until the next connect
|
||||
void clear(); // Ended, and read: the terminal and its history are given back
|
||||
State state() const { return state_; }
|
||||
bool active() const { return state_ == State::Open; } // for the Status Bar
|
||||
bool opened() const { return opened_; } // this session got as far as a shell
|
||||
std::string status() const; // what it is doing, or why it ended
|
||||
const term::SshTarget& target() const { return target_; }
|
||||
|
||||
// AskTrust: the server's fingerprint, and the one remembered if this is a different one.
|
||||
std::string fingerprint() const;
|
||||
std::string remembered() const;
|
||||
void answerTrust(bool yes);
|
||||
// AskPassword.
|
||||
void answerPassword(const std::string& password);
|
||||
|
||||
// Open (and Ended, to read what is left).
|
||||
bool hasTerminal() const { return static_cast<bool>(term_); }
|
||||
void withTerminal(const std::function<void(term::Terminal&)>& use);
|
||||
uint32_t revision() const { return revision_; }
|
||||
void send(const std::string& bytes);
|
||||
void resize(int cols, int rows);
|
||||
|
||||
// This device's own key: made once, its public half is what goes on servers.
|
||||
bool hasKey() const { return !settings_.getString(Setting::SshKey).empty(); }
|
||||
std::string publicKey() const { return settings_.getString(Setting::SshPublic); }
|
||||
std::string makeKey(); // "" or why not
|
||||
|
||||
private:
|
||||
struct Run; // what one connection's task works with
|
||||
static void task(void* arg);
|
||||
void session(Run& run);
|
||||
void end(const std::string& why);
|
||||
void say(const std::string& what);
|
||||
|
||||
Settings& settings_;
|
||||
void* lock_; // SemaphoreHandle_t
|
||||
std::atomic<State> state_{State::Idle};
|
||||
std::atomic<bool> stop_{false}, answered_{false}, trusted_{false}, running_{false}, opened_{false};
|
||||
std::atomic<uint32_t> revision_{0};
|
||||
term::SshTarget target_;
|
||||
std::unique_ptr<term::Terminal> term_;
|
||||
std::string status_, fingerprint_, remembered_, password_, outgoing_;
|
||||
int wantCols_ = 0, wantRows_ = 0;
|
||||
bool resized_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,249 @@
|
||||
#include "services/vpn_service.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <esp_wireguard.h>
|
||||
#include <lwip/dns.h>
|
||||
#include <lwip/tcpip.h>
|
||||
|
||||
#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<uint8_t*>(&text[0]), size) != static_cast<int>(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<uint16_t>(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<u16_t>(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<u8_t>(i));
|
||||
if (ip_addr_cmp(now, &wanted)) continue;
|
||||
t.dnsBefore[i] = *now;
|
||||
dns_setserver(static_cast<u8_t>(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<u8_t>(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<int32_t>(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<int32_t>(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<int64_t>(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<int32_t>(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<int32_t>(NotificationLevel::Warning)));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -221,4 +221,202 @@ const uint8_t _5x8_tf[1715] =
|
||||
"\3\374\11\64b\23\63\212f\32\375\13D^[\343(\323\210I\1\376\12<^\223\363\212#\345\14\377"
|
||||
"\14<^\23\63\212\62\215\230\24\0\0\0\0\4\377\377\0";
|
||||
|
||||
// For the terminal (issue #2): three more sizes of the same family, from the same file.
|
||||
const uint8_t _4x6_tf[1451] =
|
||||
"\277\0\2\2\3\3\2\4\4\4\6\0\377\5\377\5\377\0\351\1\323\5\216 \5\200\315\0!\6\351\310"
|
||||
"\254\0\42\6\223\313$\25#\12\254\310\244\64T\32*\1$\11\263\307\245\241GJ\0%\10\253\310d"
|
||||
"\324F\1&\11\254\310\305\24\253\230\2'\5\321\313\10(\7\362\307\251f\0)\10\262\307\304T)\0"
|
||||
"*\7\253\310\244j\65+\10\253\310\305\264b\2,\6\222\307)\0-\5\213\312\14.\5\311\310\4/"
|
||||
"\7\253\310Ve\4\60\10\253\310UCU\0\61\7\253\310%Y\15\62\7\253\310\65S\32\63\10\253\310"
|
||||
"\314\224\27\0\64\10\253\310$\65b\1\65\10\253\310\214\250\27\0\66\7\253\310M\325\2\67\10\253\310\314"
|
||||
"TF\0\70\7\253\310\255\326\2\71\7\253\310\265\344\2:\6\341\310\304\0;\7\252\307e\250\0<\7"
|
||||
"\253\310\246\272\0=\6\233\311\354\1>\7\253\310\344\252\4\77\10\253\310\350\224a\2@\6\253\310-["
|
||||
"A\10\253\310UC\251\0B\10\253\310\250\264\322\2C\10\253\310U\62U\0D\10\253\310\250d-\0"
|
||||
"E\10\253\310\214\250\342\0F\10\253\310\214\250b\4G\10\253\310\315\244\222\0H\10\253\310$\65\224\12"
|
||||
"I\7\253\310\254X\15J\7\253\310\226\252\2K\10\253\310$\265\222\12L\7\253\310\304\346\0M\10\253"
|
||||
"\310\244\61\224\12N\10\253\310\252\241$\0O\7\253\310UV\5P\10\253\310\250\264b\4Q\7\263\307"
|
||||
"UV\35R\10\253\310\250\264\222\12S\7\253\310\355\274\0T\7\253\310\254\330\2U\7\253\310$\327\10"
|
||||
"V\10\253\310$k\244\4W\10\253\310$\65\206\12X\10\253\310$\325R\1Y\10\253\310$UV\0"
|
||||
"Z\7\253\310\314T\16[\6\352\310\254J\134\7\253\310\304\134\6]\6\252\310\250j^\5\223\313\65_"
|
||||
"\5\213\307\14`\6\322\313\304\0a\7\243\310-\225\4b\10\253\310D\225\324\2c\6\243\310\315,d"
|
||||
"\10\253\310\246\245\222\0e\6\243\310USf\10\253\310\246\264b\2g\10\253\307\255$\27\0h\10\253"
|
||||
"\310D\225\254\0i\10\253\310e$\323\0j\10\263\307fX.\0k\10\253\310\304\264\222\12l\7\253"
|
||||
"\310\310\326\0m\10\243\310\244\241T\0n\7\243\310\250d\5o\7\243\310U\252\2p\10\253\307\250\264"
|
||||
"b\4q\10\253\307-\225d\0r\10\243\310\244\25#\0s\7\243\310\215\274\0t\10\253\310\245\25s"
|
||||
"\0u\7\243\310$+\11v\7\243\310$\253\2w\10\243\310$\65T\0x\7\243\310\244\62\25y\10"
|
||||
"\253\307$\225\344\2z\7\243\310\314\224\6{\10\263\307\246$\353\0|\6\351\310\14\1}\11\263\307\344"
|
||||
"\250b\212\0~\7\224\313%\225\0\240\5\200\315\0\241\6\351\310\244\1\242\10\253\310\245\21W\2\243\7"
|
||||
"\253\310\246\250\32\244\10\244\310\304$U\14\245\10\253\310\244j\305\4\246\6\351\310(\1\247\10\263\307\215"
|
||||
"T\311\5\250\6\213\314\244\0\251\11\264\307\251\270\226L\12\252\7\253\310\255\244\7\253\10\234\311%\25S"
|
||||
"\0\254\6\223\311\314\0\255\5\213\312\14\256\10\244\311\251\261\222\2\257\5\213\314\14\260\6\233\312u\1\261"
|
||||
"\10\253\310\245\225\321\0\262\6\242\311(\3\263\7\252\310(\251\0\264\6\322\313)\0\265\10\253\307$k"
|
||||
"E\0\266\7\254\310\15\265z\267\5\311\312\4\270\6\322\310)\0\271\6\242\311\255\2\272\7\253\310u\243"
|
||||
"\1\273\10\234\311\244\230T\2\274\10\264\307\344\32U;\275\10\264\307\344J\307,\276\11\264\307\350\230Q"
|
||||
"\262\3\277\10\253\310e\230\262\0\300\10\253\310\344\224\206\12\301\10\253\310\246j\250\0\302\10\253\310\310\224"
|
||||
"\206\12\303\10\253\310\215\224\206\12\304\10\253\310\244\326P\1\305\10\253\310\305\224\206\12\306\11\254\310\215\224"
|
||||
"\206\252\4\307\10\263\307U\62\65\1\310\10\253\310\304\241\342\0\311\7\253\310\216\25\7\312\10\253\310\215\221"
|
||||
"\342\0\313\10\253\310\244\261\342\0\314\10\253\310\304\25\323\0\315\10\253\310\216\24\323\0\316\10\253\310\245\25"
|
||||
"\323\0\317\7\253\310\244\262\32\320\11\254\310\314\264\252\221\0\321\10\254\310%\225\256\12\322\10\253\310\344\224"
|
||||
"T\5\323\10\253\310\246JU\0\324\10\253\310\305\224T\5\325\10\254\310\215\325\31\1\326\10\253\310\244\226"
|
||||
"\252\0\327\6\233\311\244\16\330\7\253\310\35j\1\331\10\253\310\344\224\324\10\332\10\253\310\246J\215\0\333"
|
||||
"\10\253\310e\224\324\10\334\10\253\310\244\234\324\10\335\10\253\310\346T&\0\336\10\253\310D\225V\4\337"
|
||||
"\10\263\307U+\15\11\340\10\253\310\344\270\222\0\341\10\253\310\246\270\222\0\342\10\253\310i\264\222\0\343"
|
||||
"\11\254\310%\25U\251\0\344\10\253\310\244\214V\22\345\10\253\310e\270\222\0\346\10\244\310\215\264\342\0"
|
||||
"\347\10\253\307UZ%\0\350\10\253\310\344\224\246\0\351\7\253\310\246j\12\352\10\253\310\310\224\246\0\353"
|
||||
"\7\253\310\244\326\24\354\7\253\310\344X\15\355\6\253\310\316j\356\7\253\310u\246\1\357\10\253\310\244,"
|
||||
"\323\0\360\10\253\310\244rU\0\361\11\254\310%\225dj\1\362\10\253\310\344\230Z\0\363\10\253\310\246"
|
||||
"\230Z\0\364\10\253\310e\230Z\0\365\7\253\310l\324\5\366\10\253\310\244\214\272\0\367\10\253\310e\264"
|
||||
"Q\2\370\7\243\310-\265\0\371\10\253\310\344\224T\22\372\10\253\310\246J%\1\373\10\253\310e\224T"
|
||||
"\22\374\10\253\310\244\234T\22\375\10\263\307\246j\304\5\376\11\263\307\304\250\322\212\0\377\11\263\307\244\234"
|
||||
"F\134\0\0\0\0\4\377\377\0";
|
||||
|
||||
const uint8_t _6x10_tf[2000] =
|
||||
"\277\0\2\2\3\4\3\5\4\6\12\0\376\7\376\7\0\1B\2\222\7\263 \5\0b\7!\7\71C"
|
||||
"g\250\0\42\7\233R'Y\1#\15=B\257Li\250j\250\62%\0$\13=B\67\257z\247\264"
|
||||
"#\0%\13=B/\252\356\252%\23\0&\14=B/\247\230r\225dT\1'\5\31Sg(\10"
|
||||
"\273B\67\225u\1)\10\273B'\227U\11*\12-F'\247j\250v\0+\12-F\67\243\70d"
|
||||
"F\21,\7\233>O\225\0-\6\15Ng\10.\7\233>/\255\4/\13=B\37e\224\273QF"
|
||||
"\0\60\12=B\67\247\332Nu\4\61\14=B\67\313\224QF\31\305!\62\14=Bo\345\214\242\314"
|
||||
"\31\15\1\63\14=Bgh\224\263\206:-\0\64\14=B\77\313T\246\241\63J\0\65\13=B\347"
|
||||
"F\311\314H\247\5\66\13=BW\346\214\222\251\323\2\67\14=Bgh\224\63\312\65\312\0\70\13="
|
||||
"Boe\235V\326i\1\71\14=Boe\251TF\71J\0:\12\273>/\255\14\323J\0;\11"
|
||||
"\273>/\255\14U\11<\12\274B\77\266QF\31\5=\10\35Jgh\70\4>\13\274B'\243\214"
|
||||
"\62\212m\0\77\12=Bo\345\66\312t\4@\13=Boe\271\222\225\341\2A\13=B\67\247Z"
|
||||
"\217\221u\0B\14=Bg\304*\246Y\305\241\0C\14=Boe\215\62\312(\247\5D\15=B"
|
||||
"g\304*\246\230b\212C\1E\14=B\347F\31\215\224QFCF\15=B\347F\31\215\224QF"
|
||||
"\31\1G\14=Boe\215\62\212;-\0H\11=B'\333cd;I\10\273Bg\305\256\1J"
|
||||
"\14=Bwg\224QFe\224\0K\13=B'\313T\352\24\253\34L\16=B'\243\214\62\312("
|
||||
"\243\214\206\0M\12=B'\353\265\222\266\3N\12=B'\353\251\222\334:O\11=Boe\357\264"
|
||||
"\0P\15=Bg\244\254\207\312(\243\214\0Q\12E>oe\257j\303\0R\13=Bg\244\254\207"
|
||||
"*\253\34S\13=Boe\15\67\324i\1T\16=Bg\310\214\62\312(\243\214\42\0U\10=B"
|
||||
"'\373N\13V\13=B'\333\251L\61\345\10W\12=B'\273\222Jw\0X\12=B'\353T"
|
||||
"W\265\16Y\14=B'\353Tg\224QF\21Z\12=Bgh\224\273\321\20[\10\273Bg\304\316"
|
||||
"\1\134\15=B'\243\14\63\314\60\303\214\2]\10\273Bgv\216\0^\7\35R\67\247:_\6\15"
|
||||
">g\10`\6\22['\6a\12-Bo\303\64t\32\1b\14=B'\243\214\222\251\247R\0c"
|
||||
"\12-Boe\215rZ\0d\13=B\37e\224\314-\225\12e\12-Bo\345\61\62\134\0f\14"
|
||||
"=BWVy\304\214\62\312\0g\14=:oh\235FF:-\0h\13=B'\243\214\222\251\355"
|
||||
"\0i\10\273B/#\331\32j\13\314:\77c]K\231\24\0k\14=B'\243\214\262L\263\312\1"
|
||||
"l\7\273BG\366\32m\12-BG\265TRI\7n\10-B'\231\332\16o\11-Boe;"
|
||||
"-\0p\14=:'\231z*\225QF\0q\13=:\317\334R\251\214\62\12r\13-B'\231\32"
|
||||
"e\224\21\0s\12-Boe\270\341P\0t\15=B/\243<bF\31\305\250\0u\10-B'"
|
||||
";\225\12v\12-B'\353T\246\34\1w\11-B'[Iu\1x\11-B'\247\272\252\3y"
|
||||
"\13=:'\233Je\244\323\2z\10-Bg\350\366\20{\13\274BWe\224\64\212\31\11|\6\71"
|
||||
"C\347\10}\14\274BG\243\230\221\312(I\0~\11\35R/\252$\23\0\240\5\0b\7\241\7\71"
|
||||
"C'\15\1\242\14=>\67\17\25SLy\304\10\243\13=BWVyg\24\225\2\244\12-B'"
|
||||
"\247\231\342\312\1\245\15E>'\353T\307!\63\312(\2\246\6\71Cg\15\247\13E>oe\64;"
|
||||
"\67J\13\250\6\213^'\5\251\14=Boe\225\246J:-\0\252\12\264FoD\245j\64\2\253"
|
||||
"\13.B\267\212)\346\230c\0\254\7\224Jg\344\0\255\6\214Ng\4\256\13=Bo\345\221\346\324"
|
||||
"i\1\257\6\15^g\10\260\6\233R\257\13\261\13\65B\67\243\70dFq\10\262\10\254NO\305\346"
|
||||
"\10\263\12\254Ng\243\244\321H\0\264\6\22[O\1\265\12\65>'\333S\251\214\0\266\16=Bo"
|
||||
"\214\64RR\61\305\24S\0\267\5\11O'\270\6\22;O\1\271\7\253N/\311j\272\12\264FO"
|
||||
"E\231\64\34\1\273\14.B'\346\230c\212)F\0\274\20N>/#\15\63\314hf\244S\34\31"
|
||||
"\6\275\20N>/#\15\63\314heT\303\214\62\32\276\16M>G\303\234a\224Y\246\64\62\12\277"
|
||||
"\12=B\67\323\31\345\326\2\300\14EB/\303\274\262\36#\353\0\301\13EB\277^Y\217\221u\0"
|
||||
"\302\14EB\67\247\270\262\36#\353\0\303\14EB/*\271\262\36#\353\0\304\13EB\257\246V\326"
|
||||
"cd\35\305\14EB\67\247\270\262\36#\353\0\306\13>Bw\244\262\71Fl\16\307\15M:oe"
|
||||
"\215\62\312(\247]\3\310\16EB/\217\215\62\32)\243\214\206\0\311\16EB\77\215\215\62\32)\243"
|
||||
"\214\206\0\312\16EB\67\216\215\62\32)\243\214\206\0\313\16EB\257\32\33e\64RF\31\15\1\314"
|
||||
"\11\303B'\247\25[\3\315\11\303B\67\245\25[\3\316\11\303B\257\32)\266\6\317\11\303B'\345"
|
||||
"\25[\3\320\15=Bg\304*\216T\246\70\24\0\321\13EB\67uO\225\344\326\1\322\13EB/"
|
||||
"\303\274\262;-\0\323\12EB\277^\331\235\26\0\324\13EB\67\247\270\262;-\0\325\12EB\67"
|
||||
"\65Wv\247\5\326\12EB\257\246Vv\247\5\327\11-B'\247\272\252\3\330\13=Bo\305+\315"
|
||||
"\231\26\0\331\12EB/\303\332;-\0\332\11EB\277\314\336i\1\333\13EB\67\247\214\263;-"
|
||||
"\0\334\12EB\257\306\331;-\0\335\14EB\277\314:\325\31e\24\1\336\16=B'\243\221\362P"
|
||||
"\31e\224\21\0\337\13=Boe\231\312*+\5\340\14EB/\303Ln\230\206N#\341\13EB"
|
||||
"\277&\67LC\247\21\342\14EB\67\247Lm\230\206N#\343\14EB\67\265\251\15\323\320i\4\344"
|
||||
"\13=B\257\246\66LC\247\21\345\14EB\67\247\234\67LC\247\21\346\13.BodT\215\231\207"
|
||||
"\0\347\13=:oe\215r\332\65\0\350\14EB/\303L\256<F\206\13\351\13EB\277&W\36"
|
||||
"#\303\5\352\14EB\67\247L\255<F\206\13\353\13=B\257\246V\36#\303\5\354\11\303B'g"
|
||||
"$[\3\355\10\303B\257\206\262\65\356\10\303B\257-[\3\357\10\273B'e\331\32\360\13=BG"
|
||||
"C\271\262\235\26\0\361\12EB\67\265q\62\265\35\362\13EB/\303L\256l\247\5\363\12EB\277"
|
||||
"&W\266\323\2\364\13EB\67\247L\255l\247\5\365\13EB\67\265\251\225\355\264\0\366\12=B\257"
|
||||
"\246V\266\323\2\367\11-F\67SCS\21\370\12-Bo\310\225\346P\0\371\13EB/\303Le"
|
||||
"\247R\1\372\12EB\277\246\262S\251\0\373\13EB\67\247\214\263S\251\0\374\12=B\257\306\331\251"
|
||||
"T\0\375\14M:\277\314\246R\31\351\264\0\376\15E:'\243\221\262=TF\31\1\377\15M:\257"
|
||||
"\306\331T*#\235\26\0\0\0\0\4\377\377\0";
|
||||
|
||||
const uint8_t _9x15_tf[2606] =
|
||||
"\277\0\3\2\4\4\4\5\5\11\17\0\375\12\375\13\377\1\223\3*\12\21 \5\0\310\63!\10\261\14"
|
||||
"\63\16\221\0\42\10\64{\63\42S\0#\16\206\31s\242\226a\211Z\206%j\1$\24\267\371\362\302"
|
||||
"A\211\42)L\322\65\11#\251\62\210\31\0%\21\247\11sB%JJ\255q\32\265\224\22\61\1&"
|
||||
"\22\247\11s\304(\213\262(T\65)\251E\225H\13'\6\61|\63\6(\14\303\373\262\222(\211z"
|
||||
"\213\262\0)\14\303\373\62\262(\213z\211\222\10*\15w\71\363J\225\266-i\252e\0+\13w\31"
|
||||
"\363\342\332\60dq\15,\11R\334\62\206$Q\0-\7\27I\63\16\1.\7\42\14\63\206\0/\14"
|
||||
"\247\11\263\323\70-\247\345\64\6\60\15\247\11\263\266J\352k\222e\23\0\61\15\247\11\363R\61\311\242"
|
||||
"\270\267a\10\62\14\247\11s\6%U\373y\30\2\63\16\247\11\63\16qZ\335\343XM\6\5\64\21"
|
||||
"\247\11sS\61\311\242Z\22&\303\220\306\25\0\65\17\247\11\63\16reH\304\270\254&\203\2\66\20"
|
||||
"\247\11\263\206(\215+C\42\252\326dP\0\67\16\247\11\63\16q\32\247q\32\247q\10\70\21\247\11"
|
||||
"\263\266J\232d\331VI\325$\313&\0\71\17\247\11s\6%uT\206$\256FC\4:\10r\14"
|
||||
"\63\206x\10;\12\242\334\62\206xH\22\5<\11\245\12\63\263\216i\7=\12G)\63\16\71>\14"
|
||||
"\1>\12\245\12\63\322\216YG\0\77\16\247\11s\6%U\343\264\71\207\63\0@\21\247\11s\6%"
|
||||
"\65\15J\246D\223\42\347\203\2A\15\247\11\363\322$\253\244\326\341j\15B\22\247\11\63\6)LR"
|
||||
"\61\31\244\60I\215\311 \1C\15\247\11s\6%\225\373\232\14\12\0D\15\247\11\63\6)LR\77"
|
||||
"&\203\4E\15\247\11\63\16ry\220\342\346a\10F\14\247\11\63\16ry\220\342\316\0G\17\247\11"
|
||||
"s\6%\225\333\206\324\232\14\12\0H\12\247\11\63R\327\341\352\65I\12\245\12\63\6)\354\247AJ"
|
||||
"\15\250\11\363\6\65\357S\230\15\31\0K\21\247\11\63R\61\311\242\332\230\204QV\12\223\64L\12\247"
|
||||
"\11\63\342\376<\14\1M\20\247\11\63Ru[*JE\212\244H\265\6N\17\247\11\63RuT\62"
|
||||
")\322\22q\265\6O\14\247\11s\6%\365\327dP\0P\15\247\11\63.\251u\30\222\270\63\0Q"
|
||||
"\16\307\351r\6%\365K&U\6\65\27R\20\247\11\63.\251u\30\222(+\205I\252\6S\16\247"
|
||||
"\11s\6%\265\357V\65\31\24\0T\12\247\11\63\16Y\334\337\0U\13\247\11\63R\377\232\14\12\0"
|
||||
"V\21\247\11\63Rk\222EY\224U\302$L\322\14W\20\247\11\63RO\221\24I\221\24)\335\22"
|
||||
"\0X\20\247\11\63R\65\311*i\234&Y%U\3Y\15\247\11\63R\65\311*i\334\33\0Z\14"
|
||||
"\247\11\63\16q\332\347x\30\2[\12\304\373\62\6\255\177\33\2\134\13\247\11\63\362\70/\347\345<]"
|
||||
"\12\304\372\62\206\254\177\33\4^\12Gi\363\322$\253\244\1_\7\30\370\62\16\2`\7\63\213\63\262"
|
||||
"\2a\16w\11s\6=N\206!\25\225!\11b\17\247\11\63\342\226!\21U\353\250\14\11\0c\14"
|
||||
"w\11s\6%\225[\223A\1d\15\247\11\263[\206D\134\35\225!\11e\15w\11s\6%U\207"
|
||||
"s>(\0f\16\247\11\363\266R\26\305\341 \306\215\0g\23\247\331r\206DL\302$\214\206(\37"
|
||||
"\224TM\6\5h\14\247\11\63\342\226!\21U\257\1i\12\245\12stt\354i\20j\15\326\331\62"
|
||||
"u\312\332U\64&C\2k\17\247\11\63\342VMI\64\65\321\62%\15l\11\245\12\63\306\376\64\10"
|
||||
"m\20w\11\63\26%\212\244H\212\244H\212\324\0n\13w\11\63\222!\21U\257\1o\14w\11s"
|
||||
"\6%\365\232\14\12\0p\17\247\331\62\222!\21U\353\250\14I\134\6q\15\247\331r\206D\134\35\225"
|
||||
"!\211\33r\14w\11\63\242IK\302$n\5s\15w\11s\6%\325\7]M\6\5t\14\227\11"
|
||||
"\263\342p\330\342n\331\2u\20w\11\63\302$L\302$L\302$\214\206$v\16w\11\63R\65\311"
|
||||
"\242\254\22&i\6w\16w\11\63RS$ER\244tK\0x\15w\11\63\322$\253\244\225\254\222"
|
||||
"\6y\15\246\331\62B\337\224%\25\223!\1z\12w\11\63\16i\257\303\20{\15\305\373\262\226\260\32"
|
||||
"ij\26V\7|\6\301\374\62>}\16\305\371\62\326\260\226jR\32V&\0~\12\67ys\64)"
|
||||
"\322\24\0\240\5\0\310\63\241\10\261\14\63\244a\10\242\21\206\11\63\243!\211\22\251\222%Q\62D!"
|
||||
"\0\243\21\247\11\363\266R\34\16b\234\212I\226$\13\0\244\16g\71\63\322d\220\262(\213\6%\15"
|
||||
"\245\20\247\11\63R\65\311*\331 \206\203\30\327\0\246\10\261\374\62\6e\20\247\16\264\372r\224HT"
|
||||
"\42S\42J\211\2\250\7%\232\63\62-\251\25\230\30\263\206,L\42K\224(\241\22%\222\224\204\331"
|
||||
"\20\1\252\14u\71s\244\322\22EC:\10\253\15\207\31\363\242~\213\302(\214\302(\254\7F)\63"
|
||||
"\256\15\255\7\25J\63\6\1\256\25\230\30\263\206,L\222I\211\22eRJJ\224\24\263!\2\257\6"
|
||||
"\26\231\63\16\260\12Dks\224HJ\24\0\261\16\227\31\363\342\332\60dq\35\33\206\0\262\13dI"
|
||||
"s\224(K\224l\10\263\13dIs\224\250(%\12\0\264\10\63\213\263\222\22\0\265\14\227\351\62R"
|
||||
"\257\333\262\310\61\0\266\26\247\11s\206!K\264DK\222!\11\223\60\11\223\60\11\223\0\267\7\42L"
|
||||
"\63\206\0\270\10\64\332\262\246D\1\271\10cIs\22\251e\272\12eIs\226LK\346A\273\16\207"
|
||||
"\31\63\242\60\12\243\60\312\242~\3\274\17\247\11sR\271q\210\304$\213\62%\25\275\16\247\11sR"
|
||||
"\271I\31\242\70\24\343!\276\21\247\11s\304(\315\263\250\42\211I\26eJ*\277\15\247\11\363r\270"
|
||||
"\332\234\252\311\240\0\300\16\307\11s\362:\272URu\270Z\3\301\15\307\11s\333\321\255\222\252\303\325"
|
||||
"\32\302\17\307\11\363\322$\253c[%U\207\253\65\303\17\267\11s\64i\307\266J\252\16Wk\0\304"
|
||||
"\17\267\11s\262(\313\261\255\222\252\303\325\32\305\17\267\11\263\266\332\230d\225T\35\256\326\0\306\25\247"
|
||||
"\11s\224!\312\242,\312\242lX\242,\312\242,\32\2\307\20\327\331r\6%\225\373\232\14\242\26\205"
|
||||
"\32\0\310\21\307\11s\362:\66\14I\34\17Y\134\35\206\0\311\20\307\11s\333\261aH\342x\310\342"
|
||||
"\352\60\4\312\22\307\11\363\322$\253#\303\220\304\361\220\305\325a\10\313\22\267\11s\262(\313\221aH"
|
||||
"\342x\310\342\352\60\4\314\14\305\12\63\322\372 \205=\15\2\315\14\305\12\63\263\372 \205=\15\2\316"
|
||||
"\15\305\12\263\262\244\226\16R\330\323 \317\14\265\12\63\62-\35\244\260\247A\320\25\250\10s\6-\214"
|
||||
"\322$\35\302$M\322$M\302h\220\0\321\22\267\11s\64iG\322Q\311\244H\212\264D\134\3\322"
|
||||
"\16\307\11s\362:\70(\251\257\311\240\0\323\15\307\11s\333\301AI}M\6\5\324\20\307\11\363\322"
|
||||
"$\253C\203\222\372\232\14\12\0\325\17\267\11s\64i\207\6%\365\65\31\24\0\326\17\267\11s\262("
|
||||
"\313\241AI}M\6\5\327\15w\31\63\322$\253\244\225\254\222\6\330\26\307\371\262\223A\11\267DK"
|
||||
"\244H\212\224L\311\306dPb\0\331\15\307\11s\362:\226\372\65\31\24\0\332\14\307\11s\333\261\324"
|
||||
"\257\311\240\0\333\16\307\11\363\322$\253#\251_\223A\1\334\16\267\11s\262(\313\221\324\257\311\240\0"
|
||||
"\335\16\307\11s\333\261TM\262J\32\267\1\336\16\247\11\63\342xXR\353\60$q\31\337\24\246\11"
|
||||
"\263\246,\311\222(Q\262\250\226dI\226$\12\0\340\21\267\11\263\362:\66\350q\62\14\251\250\14I"
|
||||
"\0\341\20\267\11s\333\301A\217\223aHEeH\2\342\22\267\11\363\322$\253C\203\36'\303\220\212"
|
||||
"\312\220\4\343\22\247\11\263\244$\322\241A\217\223aHEeH\2\344\22\247\11s\262(\313\241A\217"
|
||||
"\223aHEeH\2\345\22\267\11\363\304(\324\261A\217\223aHEeH\2\346\17w\11s,Q"
|
||||
"-J\6%\312\242\212\62\347\17\247\331r\6%\225[\223A\324\242P\3\350\17\267\11s\362:\70("
|
||||
"\251:\234\363A\1\351\17\267\11s\333\301AI\325\341\234\17\12\0\352\21\267\11\363\322$\253C\203\222"
|
||||
"\252\303\71\37\24\0\353\21\247\11s\262(\313\241AI\325\341\234\17\12\0\354\12\265\12\63\322\372\330\323"
|
||||
" \355\12\265\12\363\332\221\261\247A\356\15\266\11\263\302$\312rd\355m\20\357\14\245\12\63\242$\212"
|
||||
"\307\236\6\1\360\20\267\11s\242PL\362lPR\257\311\240\0\361\16\247\11s\64iG\222!\21U"
|
||||
"\257\1\362\16\267\11s\362:\70(\251\327dP\0\363\15\267\11s\333\301AI\275&\203\2\364\17\267"
|
||||
"\11\363\322$\253C\203\222zM\6\5\365\17\247\11s\64i\207\6%\365\232\14\12\0\366\17\247\11s"
|
||||
"\262(\313\241AI\275&\203\2\367\16\227\11\363\322\65\307\206!\307\322\65\3\370\23\227\371\262\223A\311"
|
||||
"\22-\221\42%S\262dPb\0\371\23\267\11s\362:\26&a\22&a\22&a\64$\1\372\22"
|
||||
"\267\11s\333\261\60\11\223\60\11\223\60\11\243!\11\373\24\267\11\363\322$\253#a\22&a\22&a"
|
||||
"\22FC\22\374\24\247\11s\242,\312\241\60\11\223\60\11\223\60\11\243!\11\375\17\346\331\62\333\241\320"
|
||||
"\67eI\305dH\0\376\20\307\331\62\342\226!\21UuT\206$.\3\377\17\326\331r\242\366\320\67"
|
||||
"eI\305dH\0\0\0\0\4\377\377\0";
|
||||
|
||||
} // namespace roro::fontdata
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace fontdata {
|
||||
extern const uint8_t _6x13_tf[];
|
||||
extern const uint8_t _6x13B_tf[];
|
||||
extern const uint8_t _5x8_tf[];
|
||||
extern const uint8_t _4x6_tf[];
|
||||
extern const uint8_t _6x10_tf[];
|
||||
extern const uint8_t _9x15_tf[];
|
||||
} // namespace fontdata
|
||||
|
||||
// Latin-1 bitmap fonts (accented letters included).
|
||||
@@ -15,6 +18,9 @@ namespace fonts {
|
||||
inline const lgfx::U8g2font body(fontdata::_6x13_tf); // 6x13
|
||||
inline const lgfx::U8g2font bold(fontdata::_6x13B_tf); // 6x13 bold
|
||||
inline const lgfx::U8g2font small(fontdata::_5x8_tf); // 5x8, Status Bar
|
||||
inline const lgfx::U8g2font tiny(fontdata::_4x6_tf); // 4x6: the terminal's smallest (issue #2)
|
||||
inline const lgfx::U8g2font medium(fontdata::_6x10_tf); // 6x10
|
||||
inline const lgfx::U8g2font large(fontdata::_9x15_tf); // 9x15
|
||||
} // namespace fonts
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -48,6 +48,12 @@ 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;
|
||||
}
|
||||
if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front
|
||||
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;
|
||||
|
||||
@@ -31,12 +31,14 @@ 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 ssh = false; // an SSH session is open (issue #2, Q260)
|
||||
|
||||
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 && ssh == o.ssh;
|
||||
}
|
||||
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
@@ -231,6 +231,35 @@ void test_fn_h_is_help_in_both_modes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #2: a key that isn't a character says what was held with it. A terminal sends Page Up for
|
||||
// Shift with the up arrow, and a note jumps to its start for Ctrl with it.
|
||||
void test_arrows_enter_and_tab_carry_what_was_held() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(true);
|
||||
RawKeys r = withFn({';'});
|
||||
r.shift = true;
|
||||
auto ev = press(m, r);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].shift);
|
||||
TEST_ASSERT_FALSE(ev[0].ctrl);
|
||||
|
||||
KeyMapper m2;
|
||||
RawKeys tab;
|
||||
tab.tab = true;
|
||||
tab.shift = true;
|
||||
ev = press(m2, tab);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].shift);
|
||||
|
||||
KeyMapper m3;
|
||||
m3.setTextEntry(true);
|
||||
RawKeys back = chars({'`'});
|
||||
back.alt = true;
|
||||
ev = press(m3, back);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_TRUE(ev[0].alt);
|
||||
}
|
||||
|
||||
// Issue #83: Fn+p everywhere.
|
||||
void test_fn_p_is_a_screenshot_in_both_modes() {
|
||||
for (bool typing : {true, false}) {
|
||||
@@ -294,6 +323,7 @@ int main() {
|
||||
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
||||
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
||||
RUN_TEST(test_fn_h_is_help_in_both_modes);
|
||||
RUN_TEST(test_arrows_enter_and_tab_carry_what_was_held);
|
||||
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
|
||||
RUN_TEST(test_plain_h_still_types);
|
||||
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ipv4.h"
|
||||
#include "net_probe.h"
|
||||
|
||||
using namespace roro::net;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_what_was_typed() {
|
||||
PingArgs p;
|
||||
TEST_ASSERT_EQUAL_STRING("", parsePing("example.org", p).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("example.org", p.host.c_str());
|
||||
TEST_ASSERT_EQUAL_INT(4, p.count);
|
||||
TEST_ASSERT_EQUAL_INT(56, p.size);
|
||||
TEST_ASSERT_EQUAL_STRING("", parsePing(" 10.9.0.1 10 1372 ", p).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("10.9.0.1", p.host.c_str());
|
||||
TEST_ASSERT_EQUAL_INT(10, p.count);
|
||||
TEST_ASSERT_EQUAL_INT(1372, p.size);
|
||||
TEST_ASSERT_EQUAL_STRING("ping <host> [count] [size]", parsePing("", p).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a count from 1 to 100", parsePing("a.example 0", p).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a count from 1 to 100", parsePing("a.example lots", p).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a size from 0 to 1400 bytes", parsePing("a.example 4 9000", p).c_str());
|
||||
TEST_ASSERT_EQUAL_INT(10, p.count); // a refused line changes nothing
|
||||
|
||||
PortArgs t;
|
||||
TEST_ASSERT_EQUAL_STRING("", parsePort("irc.libera.chat 6697", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("irc.libera.chat", t.host.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(6697, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("", parsePort("10.9.0.1:2323", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2323, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("port <host> <port>", parsePort("10.9.0.1", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parsePort("10.9.0.1 70000", t).c_str());
|
||||
|
||||
LookupArgs l;
|
||||
TEST_ASSERT_EQUAL_STRING("", parseLookup("roro9stack.net", l).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", l.server.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", parseLookup("roro9stack.net 9.9.9.9", l).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("9.9.9.9", l.server.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("the server as an address: 9.9.9.9", parseLookup("roro9stack.net dns.quad9.net", l).c_str());
|
||||
}
|
||||
|
||||
void test_an_echo_request_and_its_answers() {
|
||||
uint8_t echo[64];
|
||||
size_t len = buildEcho(echo, sizeof echo, 0xBEEF, 7, 32);
|
||||
TEST_ASSERT_EQUAL_size_t(40, len);
|
||||
TEST_ASSERT_EQUAL_UINT8(8, echo[0]);
|
||||
TEST_ASSERT_EQUAL_UINT16(0, inetChecksum(echo, len)); // a packet with its checksum in sums to nothing
|
||||
TEST_ASSERT_EQUAL_size_t(0, buildEcho(echo, 20, 1, 1, 32));
|
||||
|
||||
// The reply, as a raw socket hands it: an IP header, then the same with type 0.
|
||||
uint8_t reply[20 + 40] = {0x45, 0, 0, 60, 0, 0, 0, 0, 64, 1};
|
||||
std::copy(echo, echo + len, reply + 20);
|
||||
reply[20] = 0;
|
||||
IcmpAnswer a = parseIcmp(reply, sizeof reply);
|
||||
TEST_ASSERT_TRUE(a.kind == IcmpAnswer::Kind::Echo);
|
||||
TEST_ASSERT_EQUAL_HEX16(0xBEEF, a.id);
|
||||
TEST_ASSERT_EQUAL_UINT16(7, a.seq);
|
||||
|
||||
// A router on the way: "time exceeded", with the start of our packet inside it.
|
||||
uint8_t exceeded[20 + 8 + 20 + 8] = {0x45, 0, 0, 56, 0, 0, 0, 0, 250, 1};
|
||||
exceeded[20] = 11;
|
||||
uint8_t* inner = exceeded + 28;
|
||||
inner[0] = 0x45;
|
||||
inner[9] = 1;
|
||||
std::copy(echo, echo + 8, inner + 20);
|
||||
a = parseIcmp(exceeded, sizeof exceeded);
|
||||
TEST_ASSERT_TRUE(a.kind == IcmpAnswer::Kind::TimeExceeded);
|
||||
TEST_ASSERT_EQUAL_HEX16(0xBEEF, a.id);
|
||||
TEST_ASSERT_EQUAL_UINT16(7, a.seq);
|
||||
exceeded[20] = 3;
|
||||
TEST_ASSERT_TRUE(parseIcmp(exceeded, sizeof exceeded).kind == IcmpAnswer::Kind::Unreachable);
|
||||
|
||||
// Not ours to read: someone else's ping to us, a cut packet, UDP.
|
||||
TEST_ASSERT_TRUE(parseIcmp(reply, 22).kind == IcmpAnswer::Kind::Other);
|
||||
reply[20] = 8;
|
||||
TEST_ASSERT_TRUE(parseIcmp(reply, sizeof reply).kind == IcmpAnswer::Kind::Other);
|
||||
reply[20] = 0;
|
||||
reply[9] = 17;
|
||||
TEST_ASSERT_TRUE(parseIcmp(reply, sizeof reply).kind == IcmpAnswer::Kind::Other);
|
||||
TEST_ASSERT_TRUE(parseIcmp(exceeded, 40).kind == IcmpAnswer::Kind::Other);
|
||||
}
|
||||
|
||||
void test_the_summary() {
|
||||
PingStats s;
|
||||
s.sent = 4;
|
||||
TEST_ASSERT_EQUAL_STRING("0/4 back, 100% lost", s.summary().c_str());
|
||||
s.add(23);
|
||||
s.add(12);
|
||||
s.add(41);
|
||||
TEST_ASSERT_EQUAL_STRING("3/4 back, 25% lost, 12/25/41 ms", s.summary().c_str());
|
||||
}
|
||||
|
||||
void test_a_dns_query() {
|
||||
uint8_t q[64];
|
||||
size_t len = buildDnsQuery(q, sizeof q, 0x1234, "roro9stack.net");
|
||||
const uint8_t want[] = {0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 10, 'r', 'o', 'r', 'o', '9', 's', 't', 'a', 'c', 'k', 3, 'n', 'e', 't', 0, 0, 1, 0, 1};
|
||||
TEST_ASSERT_EQUAL_size_t(sizeof want, len);
|
||||
TEST_ASSERT_EQUAL_UINT8_ARRAY(want, q, sizeof want);
|
||||
TEST_ASSERT_EQUAL_size_t(len, buildDnsQuery(q, sizeof q, 0x1234, "roro9stack.net.")); // a final dot is the same name
|
||||
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, ""));
|
||||
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, "a..b"));
|
||||
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, std::string(64, 'a') + ".net"));
|
||||
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, 20, 1, "roro9stack.net"));
|
||||
}
|
||||
|
||||
void test_a_dns_answer() {
|
||||
// www.example.org is an alias of example.org, which has two addresses. Names after the first
|
||||
// are pointers back into the message, as servers send them.
|
||||
const uint8_t msg[] = {
|
||||
0x12, 0x34, 0x81, 0x80, 0, 1, 0, 3, 0, 0, 0, 0,
|
||||
3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0, 0, 1, 0, 1, // the question, at 12
|
||||
0xC0, 12, 0, 5, 0, 1, 0, 0, 1, 0, 0, 2, 0xC0, 16, // CNAME -> example.org (pointer to 16)
|
||||
0xC0, 16, 0, 1, 0, 1, 0, 0, 1, 0, 0, 4, 93, 184, 216, 34,
|
||||
0xC0, 16, 0, 1, 0, 1, 0, 0, 1, 0, 0, 4, 93, 184, 216, 35,
|
||||
};
|
||||
DnsAnswer a;
|
||||
TEST_ASSERT_TRUE(parseDnsAnswer(msg, sizeof msg, 0x1234, a));
|
||||
TEST_ASSERT_EQUAL_INT(0, a.rcode);
|
||||
TEST_ASSERT_FALSE(a.truncated);
|
||||
TEST_ASSERT_EQUAL_size_t(2, a.addresses.size());
|
||||
TEST_ASSERT_EQUAL_STRING("93.184.216.34", formatIpv4(a.addresses[0]).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("93.184.216.35", formatIpv4(a.addresses[1]).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("example.org", a.alias.c_str());
|
||||
|
||||
TEST_ASSERT_FALSE(parseDnsAnswer(msg, sizeof msg, 0x9999, a)); // someone else's answer
|
||||
for (size_t cut = 0; cut < sizeof msg; cut++) parseDnsAnswer(msg, cut, 0x1234, a); // cut anywhere: no crash
|
||||
TEST_ASSERT_FALSE(parseDnsAnswer(msg, sizeof msg - 3, 0x1234, a));
|
||||
|
||||
uint8_t none[sizeof msg];
|
||||
std::copy(msg, msg + sizeof msg, none);
|
||||
none[3] = 0x83; // no such name
|
||||
none[7] = 0;
|
||||
TEST_ASSERT_TRUE(parseDnsAnswer(none, 33, 0x1234, a));
|
||||
TEST_ASSERT_EQUAL_INT(3, a.rcode);
|
||||
TEST_ASSERT_EQUAL_size_t(0, a.addresses.size());
|
||||
|
||||
// A pointer that points at itself must not hang the reader.
|
||||
uint8_t loop[] = {0x12, 0x34, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xC0, 12, 0, 1, 0, 1};
|
||||
TEST_ASSERT_FALSE(parseDnsAnswer(loop, sizeof loop, 0x1234, a));
|
||||
}
|
||||
|
||||
void test_ntp() {
|
||||
uint8_t req[kNtpPacket];
|
||||
buildNtpRequest(req);
|
||||
TEST_ASSERT_EQUAL_HEX8(0x23, req[0]);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, req[47]);
|
||||
// A server's answer: stratum 2, transmit time 2026-10-08 00:45:12.5 UTC.
|
||||
uint8_t ans[kNtpPacket] = {0x24, 2};
|
||||
uint32_t secs = 1791420312u + 2208988800u;
|
||||
ans[40] = static_cast<uint8_t>(secs >> 24);
|
||||
ans[41] = static_cast<uint8_t>(secs >> 16);
|
||||
ans[42] = static_cast<uint8_t>(secs >> 8);
|
||||
ans[43] = static_cast<uint8_t>(secs);
|
||||
ans[44] = 0x80; // half a second
|
||||
NtpAnswer a;
|
||||
TEST_ASSERT_TRUE(parseNtpAnswer(ans, sizeof ans, a));
|
||||
TEST_ASSERT_EQUAL_INT(2, a.stratum);
|
||||
TEST_ASSERT_TRUE(a.seconds == 1791420312);
|
||||
TEST_ASSERT_EQUAL_UINT32(500, a.millis);
|
||||
TEST_ASSERT_FALSE(parseNtpAnswer(ans, 40, a)); // cut short
|
||||
ans[1] = 0;
|
||||
TEST_ASSERT_FALSE(parseNtpAnswer(ans, sizeof ans, a)); // "go away"
|
||||
ans[1] = 2;
|
||||
ans[0] = 0x23;
|
||||
TEST_ASSERT_FALSE(parseNtpAnswer(ans, sizeof ans, a)); // a client's packet, not a server's
|
||||
// After 2036 the count starts again from zero.
|
||||
ans[0] = 0x24;
|
||||
ans[40] = ans[41] = ans[42] = 0;
|
||||
ans[43] = 10;
|
||||
TEST_ASSERT_TRUE(parseNtpAnswer(ans, sizeof ans, a));
|
||||
TEST_ASSERT_TRUE(a.seconds == 4294967296LL + 10 - 2208988800LL);
|
||||
|
||||
TEST_ASSERT_EQUAL_STRING("right, to 0.1 s", clockOffset(1000000, 1000040).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("0.3 s ahead", clockOffset(1000300, 1000000).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("2.5 s behind", clockOffset(1000000, 1002500).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("45 s behind", clockOffset(0, 45000).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("3 min ahead", clockOffset(180000, 0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("5 h behind", clockOffset(0, 18000000).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("20552 days behind", clockOffset(0, 1775700000000LL).c_str()); // a clock still in 1970
|
||||
}
|
||||
|
||||
void test_certificates_and_ports() {
|
||||
TEST_ASSERT_EQUAL_STRING("R11", certName("C=US, O=Let's Encrypt, CN=R11").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("git.twis.la", certName("CN=git.twis.la").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("Example Org", certName("C=BE, O=Example Org").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("C=BE", certName("C=BE").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("x", certName("O=Not this, DCN=nor this, CN=x").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("*.badssl.com", certName("OU=PositiveSSL Wildcard, CN=#140C2A2E62616473736C2E636F6D").c_str()); // an old kind of string
|
||||
TEST_ASSERT_EQUAL_STRING("#14zz", certName("CN=#14zz").c_str());
|
||||
TEST_ASSERT_EQUAL_INT(1, daysBetween(2026, 10, 7, 2026, 10, 8));
|
||||
TEST_ASSERT_EQUAL_INT(53, daysBetween(2026, 10, 8, 2026, 11, 30));
|
||||
TEST_ASSERT_EQUAL_INT(-8, daysBetween(2026, 10, 8, 2026, 9, 30));
|
||||
TEST_ASSERT_EQUAL_INT(366, daysBetween(2028, 1, 1, 2029, 1, 1)); // a leap year
|
||||
TEST_ASSERT_EQUAL_INT(365, daysBetween(2100, 1, 1, 2101, 1, 1)); // and a year that isn't one
|
||||
TEST_ASSERT_EQUAL_STRING("updates", portLabel(3232, true));
|
||||
TEST_ASSERT_EQUAL_STRING("Debug Console", portLabel(2323, true));
|
||||
TEST_ASSERT_EQUAL_STRING("sharing", portLabel(80, true));
|
||||
TEST_ASSERT_EQUAL_STRING("", portLabel(80, false));
|
||||
TEST_ASSERT_EQUAL_STRING("DHCP", portLabel(68, false));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_what_was_typed);
|
||||
RUN_TEST(test_an_echo_request_and_its_answers);
|
||||
RUN_TEST(test_the_summary);
|
||||
RUN_TEST(test_a_dns_query);
|
||||
RUN_TEST(test_a_dns_answer);
|
||||
RUN_TEST(test_ntp);
|
||||
RUN_TEST(test_certificates_and_ports);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "ssh_hosts.h"
|
||||
#include "terminal.h"
|
||||
|
||||
using namespace roro::term;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
void feed(Terminal& t, const std::string& s) { t.feed(reinterpret_cast<const uint8_t*>(s.data()), s.size()); }
|
||||
#define ROW(t, r, text) TEST_ASSERT_EQUAL_STRING(text, (t).rowText(r).c_str())
|
||||
} // namespace
|
||||
|
||||
void test_text_lines_and_the_cursor() {
|
||||
Terminal t(20, 5, 10);
|
||||
feed(t, "hello\r\nworld");
|
||||
ROW(t, 0, "hello");
|
||||
ROW(t, 1, "world");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(5, t.cursorCol());
|
||||
feed(t, "\b\b!"); // backspace moves, it doesn't erase
|
||||
ROW(t, 1, "wor!d");
|
||||
feed(t, "\rW\tx"); // a tab goes to the next eighth column
|
||||
ROW(t, 1, "Wor!d x");
|
||||
feed(t, "\a");
|
||||
TEST_ASSERT_TRUE(t.takeBell());
|
||||
TEST_ASSERT_FALSE(t.takeBell());
|
||||
}
|
||||
|
||||
void test_wrapping_and_scrolling_into_history() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "0123456789"); // exactly a line: the cursor waits at the edge
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
feed(t, "ab");
|
||||
ROW(t, 0, "0123456789");
|
||||
ROW(t, 1, "ab");
|
||||
feed(t, "\r\nc\r\nd\r\ne"); // five lines through three rows
|
||||
ROW(t, 0, "c");
|
||||
ROW(t, 2, "e");
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("0123456789", t.historyLine(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("ab", t.historyLine(1).c_str());
|
||||
for (int i = 0; i < 20; i++) feed(t, "\r\nx");
|
||||
TEST_ASSERT_EQUAL_INT(5, t.historyCount()); // no more than it was given room for
|
||||
}
|
||||
|
||||
void test_moving_and_erasing() {
|
||||
Terminal t(20, 5, 0);
|
||||
feed(t, "aaaaaaaaaa\r\nbbbbbbbbbb\r\ncccccccccc");
|
||||
feed(t, "\x1b[2;4H"); // row 2, column 4
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
|
||||
feed(t, "\x1b[K"); // to the end of the line
|
||||
ROW(t, 1, "bbb");
|
||||
feed(t, "\x1b[A\x1b[1K"); // up one, erase to the start (the cursor's place included)
|
||||
ROW(t, 0, " aaaaaa");
|
||||
feed(t, "\x1b[3;1H\x1b[2K");
|
||||
ROW(t, 2, "");
|
||||
feed(t, "\x1b[1;1Hxyz\x1b[J"); // from the cursor to the end of the screen
|
||||
ROW(t, 0, "xyz");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b[5;18Hend\x1b[99;99H"); // far outside: the last cell
|
||||
TEST_ASSERT_EQUAL_INT(4, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(19, t.cursorCol());
|
||||
feed(t, "\x1b[2J");
|
||||
for (int r = 0; r < 5; r++) ROW(t, r, "");
|
||||
feed(t, "\x1b[Habcdef\x1b[1;3H\x1b[2P"); // two characters deleted in the middle
|
||||
ROW(t, 0, "abef");
|
||||
feed(t, "\x1b[2@"); // and two blanks put in
|
||||
ROW(t, 0, "ab ef");
|
||||
feed(t, "\x1b[1;1H\x1b[3X");
|
||||
ROW(t, 0, " ef");
|
||||
feed(t, "\x1b[4G!\x1b[2d?"); // column 4, then row 2 in the column after
|
||||
ROW(t, 0, " !ef");
|
||||
ROW(t, 1, " ?");
|
||||
}
|
||||
|
||||
void test_colours() {
|
||||
Terminal t(20, 3, 0);
|
||||
feed(t, "a\x1b[31mb\x1b[1mc\x1b[0;44md\x1b[7me\x1b[0m\x1b[38;5;196mf\x1b[48;2;0;0;255mg\x1b[92mh");
|
||||
TEST_ASSERT_EQUAL_HEX8(0x07, t.cell(0, 0).attr);
|
||||
TEST_ASSERT_EQUAL_HEX8(0x01, t.cell(0, 1).attr); // red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 2).attr); // bold red is bright red
|
||||
TEST_ASSERT_EQUAL_HEX8(0x47, t.cell(0, 3).attr); // on blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0x74, t.cell(0, 4).attr); // inverse: the two swapped
|
||||
TEST_ASSERT_EQUAL_HEX8(0x09, t.cell(0, 5).attr); // one of 256, as the nearest of sixteen
|
||||
TEST_ASSERT_EQUAL_HEX8(0xC9, t.cell(0, 6).attr); // a colour by its red, green and blue
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCA, t.cell(0, 7).attr); // bright green, written directly
|
||||
feed(t, "\x1b[0m\x1b[42m\x1b[2;1H\x1b[K"); // erasing paints with the colour behind
|
||||
TEST_ASSERT_EQUAL_HEX8(0x27, t.cell(1, 5).attr);
|
||||
uint8_t r, g, b;
|
||||
colourRgb(15, r, g, b);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, r);
|
||||
}
|
||||
|
||||
void test_scroll_regions_as_less_and_vim_use_them() {
|
||||
Terminal t(10, 5, 5);
|
||||
feed(t, "1\r\n2\r\n3\r\n4\r\n5");
|
||||
feed(t, "\x1b[2;4r"); // rows 2 to 4 scroll; the cursor goes home
|
||||
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
|
||||
feed(t, "\x1b[4;1H\n"); // a line feed at the bottom of the region
|
||||
ROW(t, 0, "1");
|
||||
ROW(t, 1, "3");
|
||||
ROW(t, 2, "4");
|
||||
ROW(t, 3, "");
|
||||
ROW(t, 4, "5");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount()); // a region that isn't the whole screen keeps nothing
|
||||
feed(t, "\x1b[2;1H\x1bM"); // reverse index at its top: it scrolls down
|
||||
ROW(t, 1, "");
|
||||
ROW(t, 2, "3");
|
||||
ROW(t, 3, "4");
|
||||
feed(t, "\x1b[3;1H\x1b[L"); // a line put in
|
||||
ROW(t, 2, "");
|
||||
ROW(t, 3, "3");
|
||||
feed(t, "\x1b[M"); // and taken out
|
||||
ROW(t, 2, "3");
|
||||
feed(t, "\x1b[r\x1b[1;1H\x1b[M"); // the whole screen again; a deleted first line isn't history
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
}
|
||||
|
||||
void test_the_alternate_screen() {
|
||||
Terminal t(10, 3, 5);
|
||||
feed(t, "shell$ vi");
|
||||
feed(t, "\x1b[?1049h");
|
||||
TEST_ASSERT_TRUE(t.altScreen());
|
||||
ROW(t, 0, "");
|
||||
feed(t, "~\r\n~\r\n~\r\n~"); // scrolling here is nobody's history
|
||||
TEST_ASSERT_EQUAL_INT(0, t.historyCount());
|
||||
feed(t, "\x1b[?1049l");
|
||||
TEST_ASSERT_FALSE(t.altScreen());
|
||||
ROW(t, 0, "shell$ vi"); // as it was left
|
||||
TEST_ASSERT_EQUAL_INT(9, t.cursorCol());
|
||||
}
|
||||
|
||||
void test_modes_and_what_is_asked_back() {
|
||||
Terminal t(10, 3, 0);
|
||||
std::string said;
|
||||
t.reply = [&](const std::string& s) { said += s; };
|
||||
feed(t, "\x1b[?25l");
|
||||
TEST_ASSERT_FALSE(t.cursorVisible());
|
||||
feed(t, "\x1b[?25h\x1b[?1h");
|
||||
TEST_ASSERT_TRUE(t.cursorVisible());
|
||||
TEST_ASSERT_TRUE(t.appCursorKeys());
|
||||
feed(t, "\x1b[2;5H\x1b[6n");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[2;5R", said.c_str());
|
||||
said.clear();
|
||||
feed(t, "\x1b[c");
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[?6c", said.c_str());
|
||||
feed(t, "\x1b[?7l\x1b[1;1H0123456789abc"); // no wrapping: the last column is overwritten
|
||||
ROW(t, 0, "012345678c");
|
||||
ROW(t, 1, "");
|
||||
feed(t, "\x1b" "c"); // a full reset
|
||||
ROW(t, 0, "");
|
||||
TEST_ASSERT_FALSE(t.appCursorKeys());
|
||||
}
|
||||
|
||||
void test_what_is_skipped_and_what_is_replaced() {
|
||||
Terminal t(30, 3, 0);
|
||||
feed(t, "\x1b]0;a window title\x07" "A"); // a title: not shown
|
||||
feed(t, "\x1b]2;another\x1b\\" "B"); // ended the other way
|
||||
feed(t, "\x1b[?2004h\x1b[>4;2m\x1b=" "C"); // modes this has no use for
|
||||
ROW(t, 0, "ABC");
|
||||
feed(t, "\r\n\xC3\xA9t\xC3\xA9 \xE2\x82\xAC \xE2\x94\x8C\xE2\x94\x80\xE2\x94\x90 \xE2\x94\x82 \xF0\x9F\x98\x80"); // été € ┌─┐ │ 😀
|
||||
ROW(t, 1, "\xE9t\xE9 ? +-+ | ?");
|
||||
feed(t, "\r\n\x1b(0lqk\x1b(B lqk"); // the old way of drawing lines, then letters again
|
||||
ROW(t, 2, "+-+ lqk");
|
||||
feed(t, "\x1b[2J\x1b[H\xC3("); // a character cut short doesn't swallow what follows
|
||||
ROW(t, 0, "(");
|
||||
// Nonsense must not get it stuck or out of its grid.
|
||||
std::string noise;
|
||||
for (int i = 0; i < 4000; i++) noise += static_cast<char>((i * 73 + i / 7) & 0xFF);
|
||||
feed(t, noise);
|
||||
feed(t, "\x1b" "c" "ok");
|
||||
ROW(t, 0, "ok");
|
||||
}
|
||||
|
||||
void test_a_new_size() {
|
||||
Terminal t(10, 4, 10);
|
||||
feed(t, "one\r\ntwo\r\nthree\r\nfour");
|
||||
t.resize(20, 2); // fewer rows: the cursor's line stays, the top goes to the history
|
||||
ROW(t, 0, "three");
|
||||
ROW(t, 1, "four");
|
||||
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
|
||||
TEST_ASSERT_EQUAL_INT(2, t.historyCount());
|
||||
TEST_ASSERT_EQUAL_STRING("two", t.historyLine(1).c_str());
|
||||
t.resize(4, 6); // narrower: what doesn't fit is cut
|
||||
ROW(t, 0, "thre");
|
||||
feed(t, "\r\n123456");
|
||||
ROW(t, 2, "1234");
|
||||
ROW(t, 3, "56");
|
||||
}
|
||||
|
||||
void test_keys() {
|
||||
TEST_ASSERT_EQUAL_STRING("a", encodeKey(TermKey::Char, 'a', false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'c', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x03", encodeKey(TermKey::Char, 'C', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Char, '[', true, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(1, encodeKey(TermKey::Char, ' ', true, false, false).size()); // Ctrl+Space is a zero
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b" "b", encodeKey(TermKey::Char, 'b', false, true, false).c_str()); // Alt is Esc first
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", encodeKey(TermKey::Char, 0xE9, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[A", encodeKey(TermKey::Up, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1bOA", encodeKey(TermKey::Up, 0, false, false, true).c_str()); // as vim asks for them
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[D", encodeKey(TermKey::Left, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\r", encodeKey(TermKey::Enter, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x7f", encodeKey(TermKey::Backspace, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b", encodeKey(TermKey::Escape, 0, false, false, false).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("\x1b[5~", encodeKey(TermKey::PageUp, 0, false, false, false).c_str());
|
||||
}
|
||||
|
||||
void test_who_to_connect_to() {
|
||||
SshTarget t;
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("pi@raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("pi", t.user.c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local", t.host.c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(22, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("pi@raspberrypi.local", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("raspberrypi.local:22", t.hostPort().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", parseSshTarget("root@10.9.0.1:2222", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port);
|
||||
TEST_ASSERT_EQUAL_STRING("root@10.9.0.1:2222", t.text().c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("raspberrypi.local", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("user@host, please", parseSshTarget("@host", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:0", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parseSshTarget("a@b:99999", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@b c", t).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("that isn't a host", parseSshTarget("a@", t).c_str());
|
||||
TEST_ASSERT_EQUAL_UINT16(2222, t.port); // a refused line changes nothing
|
||||
}
|
||||
|
||||
void test_saved_hosts_and_the_servers_met() {
|
||||
SshHosts hosts;
|
||||
SshTarget a, b;
|
||||
parseSshTarget("pi@one", a);
|
||||
parseSshTarget("root@two:2222", b);
|
||||
hosts.used(a);
|
||||
hosts.used(b);
|
||||
hosts.used(a); // again: to the front, not twice
|
||||
TEST_ASSERT_EQUAL_size_t(2, hosts.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("pi@one", hosts.list()[0].c_str());
|
||||
SshHosts again(hosts.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("root@two:2222", again.list()[1].c_str());
|
||||
for (int i = 0; i < 12; i++) {
|
||||
SshTarget t;
|
||||
parseSshTarget("u@h" + std::to_string(i), t);
|
||||
again.used(t);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_size_t(8, again.list().size());
|
||||
TEST_ASSERT_EQUAL_STRING("u@h11", again.list()[0].c_str());
|
||||
again.remove(0);
|
||||
TEST_ASSERT_EQUAL_STRING("u@h10", again.list()[0].c_str());
|
||||
TEST_ASSERT_EQUAL_size_t(0, SshHosts("garbage\n\n@@\n").list().size());
|
||||
|
||||
SshKnownHosts known;
|
||||
TEST_ASSERT_EQUAL_STRING("", known.fingerprintOf("one:22").c_str());
|
||||
known.remember("one:22", "SHA256:aaaa");
|
||||
known.remember("two:2222", "SHA256:bbbb");
|
||||
known.remember("one:22", "SHA256:cccc"); // it changed, and the user said yes
|
||||
SshKnownHosts stored(known.stored());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:cccc", stored.fingerprintOf("one:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:bbbb", stored.fingerprintOf("two:2222").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:2222").c_str()); // another port is another server
|
||||
for (int i = 0; i < 20; i++) stored.remember("h" + std::to_string(i) + ":22", "SHA256:x");
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("one:22").c_str()); // sixteen kept, the oldest gone
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h19:22").c_str());
|
||||
stored.forget("h19:22"); // forgotten with its host: met again, it is asked about again
|
||||
TEST_ASSERT_EQUAL_STRING("", stored.fingerprintOf("h19:22").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("SHA256:x", stored.fingerprintOf("h18:22").c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_text_lines_and_the_cursor);
|
||||
RUN_TEST(test_wrapping_and_scrolling_into_history);
|
||||
RUN_TEST(test_moving_and_erasing);
|
||||
RUN_TEST(test_colours);
|
||||
RUN_TEST(test_scroll_regions_as_less_and_vim_use_them);
|
||||
RUN_TEST(test_the_alternate_screen);
|
||||
RUN_TEST(test_modes_and_what_is_asked_back);
|
||||
RUN_TEST(test_what_is_skipped_and_what_is_replaced);
|
||||
RUN_TEST(test_a_new_size);
|
||||
RUN_TEST(test_keys);
|
||||
RUN_TEST(test_who_to_connect_to);
|
||||
RUN_TEST(test_saved_hosts_and_the_servers_met);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#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();
|
||||
}
|
||||