Public Access
VPN: a WireGuard tunnel (#8)
The device joins a WireGuard network over whatever Wi-Fi it is on: one peer, IPv4. A client's .conf is imported from the card (/vpn/wg0.conf) and kept in the device's settings, private key included, never shown; Settings offers to delete the file. A switch brings the tunnel up until the next restart, "Start with Wi-Fi" every time; it waits for the clock, which a handshake needs. VPN shows in the Status Bar. The protocol is esphome/wireguard 0.4.8. It calls lwIP without lwIP's lock, which this framework checks: every call into it is made with the lock held. What goes through the tunnel is everything (AllowedIPs 0.0.0.0/0) or the one subnet the device's tunnel address is in: lwIP routes by an interface's subnet or by default, nothing finer. The import says how many ranges it can't reach. Checked against a test peer in both directions and against a real server, with a configuration uploaded from a phone (docs/milestones/N1.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -39,6 +39,7 @@ 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
|
||||
vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself
|
||||
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
|
||||
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
|
||||
crash abort|wdt crash on purpose (to test crash reports and Safe Mode)
|
||||
@@ -109,6 +110,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `coredump erase` | Forgets the core dump |
|
||||
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
|
||||
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
|
||||
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
|
||||
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
|
||||
| `debug on` / `debug token <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 |
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
+++
|
||||
title = "Network tools"
|
||||
description = "Reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours."
|
||||
weight = 100
|
||||
|
||||
[extra]
|
||||
docs = true
|
||||
source = "docs/milestones/N1.md"
|
||||
tag = "N1"
|
||||
+++
|
||||
**Status:** in progress. The WireGuard tunnel (issue #8) is built. SSH (#2) is not started.
|
||||
|
||||
**Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.
|
||||
|
||||
## The WireGuard tunnel (issue #8)
|
||||
|
||||
A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on.
|
||||
|
||||
### Measured before deciding (2026-10-07)
|
||||
|
||||
The issue asked for the libraries to be measured first. `esphome/wireguard` 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container.
|
||||
|
||||
| | Cost |
|
||||
|---|---|
|
||||
| Flash, the library | 43 KB |
|
||||
| Flash, with our service, page and commands | 63 KB |
|
||||
| Static RAM | 1.2 KB |
|
||||
| Heap with the tunnel up | 1.8 KB |
|
||||
|
||||
- **It crashes this build as shipped.** The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (`CONFIG_LWIP_CHECK_THREAD_SAFETY`): the first `netif_add` stopped the device. Every call into it is made with the lock held, on our side; the library is not changed.
|
||||
- **One address range is allowed by default;** more need `CONFIG_WIREGUARD_MAX_SRC_IPS`, set in `platformio.ini`.
|
||||
- One peer, IPv4.
|
||||
- The older `ciniml/WireGuard-ESP32` was last touched in 2021 and was not tried.
|
||||
|
||||
### Decisions (design round 2026-10-07)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q243 | **`esphome/wireguard`, pinned at 0.4.8,** with lwIP's lock taken around every call. |
|
||||
| Q244 | **Configured by importing a standard `.conf` from the card** (`/vpn/wg0.conf`), from Settings or with `vpn import`. Nothing is typed on the device. |
|
||||
| Q245 | **The private key comes in that file,** as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings **offers to delete the file**: the card comes out, and the key is in it in clear. |
|
||||
| Q246 | One tunnel, one peer. |
|
||||
| Q247 | **A switch, and "Start with Wi-Fi"** (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first. |
|
||||
| Q248 | *Narrowed while building.* **Either everything goes through the tunnel, or one subnet does.** With `0.0.0.0/0` in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. **A home network behind the server can't be reached without the full tunnel:** lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach. |
|
||||
| Q249 | *Not as planned.* **With everything through the tunnel, nothing leaves while the server is silent:** the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer. |
|
||||
| Q250 | The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops. |
|
||||
| Q251 | **The Debug Console and the Update Service answer over the tunnel** as they do on Wi-Fi: the console still wants its token and an update its signature. |
|
||||
| Q252 | **`VPN` in the Status Bar** while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. `vpn status`, `up`, `down`, `import`, `forget`, `auto`. A Toast when it comes up and when the server stops answering. |
|
||||
| Q253 | PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/net/src/wg_config.h`** (host-tested, 6 tests): reads a `.conf` as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed.
|
||||
- **`VpnService`** (`src/services/vpn_service`): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later.
|
||||
- **The tunnel's own packets never go into the tunnel:** the library sends them on the interface that was the default when it started.
|
||||
- **Connections that came in over Wi-Fi stay on Wi-Fi** with everything routed into the tunnel: a reply leaves by the interface whose address it carries.
|
||||
- **`vpn up <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 uploaded from a phone through the Storage App's sharing (issue #88) and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console.
|
||||
|
||||
**Not checked:** from a network far from the server (the device was on the server's own network, reaching it by its public name). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime.
|
||||
|
||||
### What went wrong while building it
|
||||
|
||||
**The device stopped on the first try,** on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named `netif_add` and the line that called it.
|
||||
|
||||
**Taking the tunnel down reconnected Wi-Fi.** The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed `vpn down` among them. The servers that were there are now simply remembered and put back.
|
||||
|
||||
**"What AllowedIPs say" was more than the network stack can do.** The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which.
|
||||
@@ -59,6 +59,10 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d
|
||||
|
||||
A secure connection takes about 52 KB of the 107 KB the device has, and IRC's takes about 40 KB. Both together do not always fit. See [When a connection says "not enough memory"](/howto/not-enough-memory/).
|
||||
|
||||
## Can it use a VPN?
|
||||
|
||||
Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf` to the SD card and importing it in Settings. It can carry everything, or just the VPN's own subnet. See [VPN](/guide/vpn/).
|
||||
|
||||
## How do I copy files to and from my phone?
|
||||
|
||||
In the Storage App, press <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).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
title = "Every key"
|
||||
description = "The keys of every screen of the firmware, as the help panel lists them on the device: one table for each screen and state."
|
||||
weight = 13
|
||||
weight = 14
|
||||
[extra]
|
||||
tag = "Reference"
|
||||
+++
|
||||
|
||||
@@ -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 = 13
|
||||
[extra]
|
||||
tag = "Firmware"
|
||||
screens = ["update.png"]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
+++
|
||||
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"
|
||||
+++
|
||||
|
||||
The Cardputer can join a **WireGuard** network over whatever Wi-Fi it is on. Two uses: reaching your own machines from anywhere (an IRC bouncer, the device's own [Debug Console](/dev/debug/)), and keeping its traffic private on a hotel or café network.
|
||||
|
||||
You need a WireGuard server, yours or a provider's, and the configuration file it gives a client: a `.conf`.
|
||||
|
||||
## Setting it up
|
||||
|
||||
1. On the server, make a configuration for a new client, as you would for a phone.
|
||||
2. Copy the file to the SD card as **`/vpn/wg0.conf`**.
|
||||
3. On the device: **Settings → VPN → Import /vpn/wg0.conf**.
|
||||
4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear.
|
||||
|
||||
If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported.
|
||||
|
||||
## Using it
|
||||
|
||||
**Settings → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard.
|
||||
|
||||
- **The switch is for now.** It doesn't survive a restart.
|
||||
- **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects.
|
||||
- The tunnel waits for the clock: WireGuard needs the time. The clock is set over plain Wi-Fi first, or from GNSS.
|
||||
- A [Toast](/guide/basics/#toasts) says when the tunnel comes up, and when the server stops answering.
|
||||
|
||||
## What goes through it
|
||||
|
||||
That depends on the `AllowedIPs` line of the file, and there are only two cases:
|
||||
|
||||
| The file says | What happens |
|
||||
|---|---|
|
||||
| `AllowedIPs = 0.0.0.0/0` | **Everything** goes through the tunnel. While the server is silent, nothing leaves the device at all. |
|
||||
| Anything else | **One subnet** goes through it: the one the device's own tunnel address is in (for `10.9.0.2` with `AllowedIPs = 10.9.0.0/24`, that is `10.9.0.x`). The rest goes out on Wi-Fi as before. |
|
||||
|
||||
**A home network behind the server can only be reached with the first kind.** If the file lists `10.9.0.0/24, 192.168.1.0/24`, the second range isn't routed, and the import says so: "through it 10.9.0.0/24, not 1 other range". This is a limit of the device's network software, which can route by one subnet or by default and nothing finer.
|
||||
|
||||
The DNS servers in the file are used while the tunnel is up, if they can be reached through it.
|
||||
|
||||
## Being reached through it
|
||||
|
||||
With the tunnel up, the device answers on its tunnel address as it does on Wi-Fi: the [Debug Console](/dev/debug/) if you switched it on (it still wants its token), and the port that receives firmware updates (they still have to be signed).
|
||||
|
||||
## From the Shell
|
||||
|
||||
```
|
||||
vpn status what it is doing
|
||||
vpn up on, until the next restart
|
||||
vpn up 120 on for two minutes, then off by itself
|
||||
vpn down
|
||||
vpn import reads /vpn/wg0.conf (or the path you give)
|
||||
vpn forget stops it and erases its keys from the device
|
||||
vpn auto on|off start with Wi-Fi
|
||||
```
|
||||
|
||||
`vpn up` with a number of seconds is for trying a new configuration from a distance: if it cuts you off, it comes back by itself.
|
||||
|
||||
## Limits
|
||||
|
||||
One tunnel, to one server. IPv4 only: IPv6 addresses in the file are left out. The server can be an address or a name.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
{{ keys(scopes=["vpn"]) }}
|
||||
@@ -357,6 +357,14 @@ rows = [
|
||||
["Tab", "the file as hex"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "vpn"
|
||||
title = "Settings, VPN"
|
||||
rows = [
|
||||
["Enter", "switch, import, forget"],
|
||||
["; .", "up, down"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "notes"
|
||||
title = "Notes, the list"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user