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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -157,6 +157,12 @@ A new note has no file until something is typed; its file is then named after it
|
|||||||
|
|
||||||
**A note can be any size** (issue #47): the editor keeps a window of about 8 KB around the cursor in memory and the rest on the card, so a megabyte opens as fast as a line and uses the same 17 KB. Up to 64 KB a save rewrites the file. Above, the five-second save writes only what changed to a side file, `<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.
|
**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.
|
||||||
|
|
||||||
|
## 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
|
## 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`, `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.
|
||||||
@@ -215,6 +221,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
|||||||
| `coredump erase` | Forgets the core dump |
|
| `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 |
|
| `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 |
|
| `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 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 |
|
| `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 |
|
| `help` | Lists the commands |
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# N1 — Network tools
|
||||||
|
|
||||||
|
**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. The device, on a guest Wi-Fi, 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.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**Not checked:** the usual direction, the device calling the server, which the test network didn't allow: it needs a server the device can reach. A server named by a host name (the test used an address). PresharedKey and MTU from a file (parsed and host-tested, not used on the air). Roaming from one Wi-Fi to another. IRC and Gemini 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.
|
||||||
@@ -24,6 +24,7 @@ const RowDef kRows[] = {
|
|||||||
{Row::Coordinates, Kind::Toggle, "Coordinates"},
|
{Row::Coordinates, Kind::Toggle, "Coordinates"},
|
||||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
{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::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"},
|
{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::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
|
||||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
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";
|
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
||||||
default: return "";
|
default: return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace roro {
|
|||||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||||
class SettingsMenu {
|
class SettingsMenu {
|
||||||
public:
|
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 };
|
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||||
|
|
||||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||||
|
|||||||
@@ -301,6 +301,12 @@ inline constexpr KeyHelp kViewerImage[] = {
|
|||||||
{"Tab", "the file as hex"},
|
{"Tab", "the file as hex"},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// vpn: Settings, VPN
|
||||||
|
inline constexpr KeyHelp kVpn[] = {
|
||||||
|
{"Enter", "switch, import, forget"},
|
||||||
|
{"; .", "up, down"},
|
||||||
|
};
|
||||||
|
|
||||||
// notes: Notes, the list
|
// notes: Notes, the list
|
||||||
inline constexpr KeyHelp kNotes[] = {
|
inline constexpr KeyHelp kNotes[] = {
|
||||||
{"; .", "up, down"},
|
{"; .", "up, down"},
|
||||||
|
|||||||
@@ -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 "debug_auth.h"
|
||||||
#include "ipv4.h"
|
#include "ipv4.h"
|
||||||
|
#include "wg_config.h"
|
||||||
|
|
||||||
namespace roro {
|
namespace roro {
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ const Definition kDefinitions[] = {
|
|||||||
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
{"debug_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
|
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
||||||
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
|
{"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses
|
||||||
|
{"vpn_auto", Kind::Bool, 0, nullptr, 0, 1},
|
||||||
};
|
};
|
||||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||||
"every Setting needs a definition");
|
"every Setting needs a definition");
|
||||||
@@ -103,6 +106,10 @@ bool Settings::validString(Setting s, const std::string& value) const {
|
|||||||
if (s == Setting::Ntp1) return net::validHost(value);
|
if (s == Setting::Ntp1) return net::validHost(value);
|
||||||
if (s == Setting::Ntp2) return value.empty() || 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::DebugToken) return value.empty() || debug::validToken(value);
|
||||||
|
if (s == Setting::VpnConfig) {
|
||||||
|
net::WgConfig config;
|
||||||
|
return value.empty() || net::parseWgConf(value, config).empty();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ enum class Setting : uint8_t {
|
|||||||
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
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
|
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)
|
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
||||||
|
VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8)
|
||||||
|
VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on)
|
||||||
Count
|
Count
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ monitor_speed = 115200
|
|||||||
build_flags =
|
build_flags =
|
||||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
-DARDUINO_USB_MODE=1
|
-DARDUINO_USB_MODE=1
|
||||||
|
-DCONFIG_WIREGUARD_MAX_SRC_IPS=4
|
||||||
lib_deps =
|
lib_deps =
|
||||||
m5stack/M5Cardputer @ 1.1.1
|
m5stack/M5Cardputer @ 1.1.1
|
||||||
jgromes/RadioLib @ 7.8.1
|
jgromes/RadioLib @ 7.8.1
|
||||||
|
esphome/wireguard @ 0.4.8
|
||||||
test_ignore = *
|
test_ignore = *
|
||||||
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
|
; 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
|
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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 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
|
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)
|
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 |
|
| `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 |
|
| `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 |
|
| `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 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 |
|
| `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 |
|
| `help` | Lists the commands |
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
+++
|
||||||
|
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. The device, on a guest Wi-Fi, 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.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
**Not checked:** the usual direction, the device calling the server, which the test network didn't allow: it needs a server the device can reach. A server named by a host name (the test used an address). PresharedKey and MTU from a file (parsed and host-tested, not used on the air). Roaming from one Wi-Fi to another. IRC and Gemini 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/).
|
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/).
|
||||||
|
|
||||||
## Do I need an SD card?
|
## 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/).
|
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/).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
+++
|
+++
|
||||||
title = "Every key"
|
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."
|
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]
|
[extra]
|
||||||
tag = "Reference"
|
tag = "Reference"
|
||||||
+++
|
+++
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
+++
|
+++
|
||||||
title = "Updates"
|
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."
|
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]
|
[extra]
|
||||||
tag = "Firmware"
|
tag = "Firmware"
|
||||||
screens = ["update.png"]
|
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"]) }}
|
||||||
@@ -349,6 +349,14 @@ rows = [
|
|||||||
["Tab", "the file as hex"],
|
["Tab", "the file as hex"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[scope]]
|
||||||
|
id = "vpn"
|
||||||
|
title = "Settings, VPN"
|
||||||
|
rows = [
|
||||||
|
["Enter", "switch, import, forget"],
|
||||||
|
["; .", "up, down"],
|
||||||
|
]
|
||||||
|
|
||||||
[[scope]]
|
[[scope]]
|
||||||
id = "notes"
|
id = "notes"
|
||||||
title = "Notes, the list"
|
title = "Notes, the list"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ REPO = SITE.parent
|
|||||||
OUT = SITE / "content" / "dev"
|
OUT = SITE / "content" / "dev"
|
||||||
REPO_URL = re.search(r'repo\s*=\s*"([^"]+)"', (SITE / "config.toml").read_text()).group(1)
|
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.
|
# 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.
|
# They stay in the repository.
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ bool SettingsApp::onKey(const KeyEvent& e) {
|
|||||||
case Page::Firmware:
|
case Page::Firmware:
|
||||||
if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
|
if (!firmwarePage_.onKey(e)) page_ = Page::Menu;
|
||||||
return true;
|
return true;
|
||||||
|
case Page::Vpn:
|
||||||
|
if (!vpnPage_.onKey(e)) page_ = Page::Menu;
|
||||||
|
return true;
|
||||||
case Page::Debug:
|
case Page::Debug:
|
||||||
if (!debugPage_.onKey(e)) page_ = Page::Menu;
|
if (!debugPage_.onKey(e)) page_ = Page::Menu;
|
||||||
return true;
|
return true;
|
||||||
@@ -79,6 +82,10 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
|
|||||||
page_ = Page::Firmware;
|
page_ = Page::Firmware;
|
||||||
firmwarePage_.enter();
|
firmwarePage_.enter();
|
||||||
break;
|
break;
|
||||||
|
case Row::Vpn:
|
||||||
|
page_ = Page::Vpn;
|
||||||
|
vpnPage_.enter();
|
||||||
|
break;
|
||||||
case Row::DebugConsole:
|
case Row::DebugConsole:
|
||||||
page_ = Page::Debug;
|
page_ = Page::Debug;
|
||||||
debugPage_.enter();
|
debugPage_.enter();
|
||||||
@@ -139,6 +146,7 @@ void SettingsApp::help(std::vector<KeyHelp>& out) const {
|
|||||||
case Page::Wifi: wifiPage_.help(out); break;
|
case Page::Wifi: wifiPage_.help(out); break;
|
||||||
case Page::Firmware: firmwarePage_.help(out); break;
|
case Page::Firmware: firmwarePage_.help(out); break;
|
||||||
case Page::Debug: debugPage_.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::Wifi: return wifiPage_.helpTitle();
|
||||||
case Page::Firmware: return firmwarePage_.helpTitle();
|
case Page::Firmware: return firmwarePage_.helpTitle();
|
||||||
case Page::Debug: return debugPage_.helpTitle();
|
case Page::Debug: return debugPage_.helpTitle();
|
||||||
|
case Page::Vpn: return "VPN";
|
||||||
case Page::About: return "About";
|
case Page::About: return "About";
|
||||||
default: return nullptr;
|
default: return nullptr;
|
||||||
}
|
}
|
||||||
@@ -154,7 +163,7 @@ const char* SettingsApp::helpTitle() const {
|
|||||||
|
|
||||||
void SettingsApp::update(uint32_t nowMs) {
|
void SettingsApp::update(uint32_t nowMs) {
|
||||||
// Live values on About and Firmware.
|
// 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());
|
(page_ == Page::Wifi && wifiPage_.live());
|
||||||
if (live && nowMs - lastRefreshMs_ >= 500) {
|
if (live && nowMs - lastRefreshMs_ >= 500) {
|
||||||
lastRefreshMs_ = nowMs;
|
lastRefreshMs_ = nowMs;
|
||||||
@@ -213,6 +222,7 @@ void SettingsApp::draw(Canvas& c) {
|
|||||||
case Page::Wifi: wifiPage_.draw(c); break;
|
case Page::Wifi: wifiPage_.draw(c); break;
|
||||||
case Page::Firmware: firmwarePage_.draw(c); break;
|
case Page::Firmware: firmwarePage_.draw(c); break;
|
||||||
case Page::Debug: debugPage_.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/clock_service.h"
|
||||||
#include "services/storage_service.h"
|
#include "services/storage_service.h"
|
||||||
#include "apps/debug_console_page.h"
|
#include "apps/debug_console_page.h"
|
||||||
|
#include "apps/vpn_page.h"
|
||||||
#include "apps/firmware_page.h"
|
#include "apps/firmware_page.h"
|
||||||
#include "apps/wifi_settings_page.h"
|
#include "apps/wifi_settings_page.h"
|
||||||
#include "settings_menu.h"
|
#include "settings_menu.h"
|
||||||
@@ -31,6 +32,7 @@ struct SettingsAppDeps {
|
|||||||
WifiService& wifi;
|
WifiService& wifi;
|
||||||
SavedNetworks& savedNetworks;
|
SavedNetworks& savedNetworks;
|
||||||
UpdateService& update;
|
UpdateService& update;
|
||||||
|
VpnService& vpn;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages.
|
// 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),
|
menu_(deps.settings),
|
||||||
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
|
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
|
||||||
firmwarePage_(deps.update, deps.wifi, deps.storage),
|
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;
|
void onEnter() override;
|
||||||
bool onKey(const KeyEvent& e) override;
|
bool onKey(const KeyEvent& e) override;
|
||||||
void update(uint32_t nowMs) 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
|
bool showsSecret() const override { return page_ == Page::Debug; } // the Debug Console's token
|
||||||
|
|
||||||
private:
|
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 onMenuKey(const KeyEvent& e);
|
||||||
bool onTextKey(const KeyEvent& e);
|
bool onTextKey(const KeyEvent& e);
|
||||||
@@ -69,6 +72,7 @@ class SettingsApp : public App {
|
|||||||
WifiSettingsPage wifiPage_;
|
WifiSettingsPage wifiPage_;
|
||||||
FirmwarePage firmwarePage_;
|
FirmwarePage firmwarePage_;
|
||||||
DebugConsolePage debugPage_;
|
DebugConsolePage debugPage_;
|
||||||
|
VpnPage vpnPage_;
|
||||||
Page page_ = Page::Menu;
|
Page page_ = Page::Menu;
|
||||||
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
ListModel list_{theme::kContent.h / theme::kLineHeight};
|
||||||
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
ListModel choices_{theme::kContent.h / theme::kLineHeight};
|
||||||
|
|||||||
@@ -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
|
||||||
+49
-2
@@ -12,6 +12,7 @@
|
|||||||
#include "apps/demo_app.h"
|
#include "apps/demo_app.h"
|
||||||
#include "apps/note_editor.h"
|
#include "apps/note_editor.h"
|
||||||
#include "apps/shell_app.h"
|
#include "apps/shell_app.h"
|
||||||
|
#include "services/vpn_service.h"
|
||||||
#include "apps/gemini_app.h"
|
#include "apps/gemini_app.h"
|
||||||
#include "apps/gnss_app.h"
|
#include "apps/gnss_app.h"
|
||||||
#include "apps/irc_app.h"
|
#include "apps/irc_app.h"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
#include "event_bus.h"
|
#include "event_bus.h"
|
||||||
#include "file_receiver.h"
|
#include "file_receiver.h"
|
||||||
#include "ipv4.h"
|
#include "ipv4.h"
|
||||||
|
#include "wg_config.h"
|
||||||
#include "traffic.h"
|
#include "traffic.h"
|
||||||
#include "key_mapper.h"
|
#include "key_mapper.h"
|
||||||
#include "platform/console.h"
|
#include "platform/console.h"
|
||||||
@@ -85,6 +87,7 @@ static WifiService* wifi;
|
|||||||
static IrcService* irc;
|
static IrcService* irc;
|
||||||
static UpdateService* update;
|
static UpdateService* update;
|
||||||
static DebugConsole* debugConsole;
|
static DebugConsole* debugConsole;
|
||||||
|
static VpnService* vpnService; // not in Safe Mode
|
||||||
static Notifier* notifier;
|
static Notifier* notifier;
|
||||||
static LauncherApp launcher;
|
static LauncherApp launcher;
|
||||||
static AppManager* apps;
|
static AppManager* apps;
|
||||||
@@ -131,6 +134,8 @@ static StatusInfo currentStatus() {
|
|||||||
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
|
s.radio = last && millis() - last < 400 ? StatusInfo::Radio::Packet : StatusInfo::Radio::Listening;
|
||||||
}
|
}
|
||||||
s.capturing = loraCapture && loraCapture->capturing();
|
s.capturing = loraCapture && loraCapture->capturing();
|
||||||
|
if (vpnService && vpnService->wanted())
|
||||||
|
s.vpn = vpnService->state() == VpnService::State::Up ? StatusInfo::Vpn::Up : StatusInfo::Vpn::Trying;
|
||||||
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
|
s.debug = !debugConsole->on() ? StatusInfo::Debug::Off : debugConsole->clientConnected() ? StatusInfo::Debug::Client : StatusInfo::Debug::On;
|
||||||
using WifiState = WifiController::State;
|
using WifiState = WifiController::State;
|
||||||
switch (wifi->state()) {
|
switch (wifi->state()) {
|
||||||
@@ -208,6 +213,8 @@ void setup() {
|
|||||||
services.add(*update);
|
services.add(*update);
|
||||||
debugConsole = new DebugConsole(*wifi, *storageService, settings);
|
debugConsole = new DebugConsole(*wifi, *storageService, settings);
|
||||||
services.add(*debugConsole);
|
services.add(*debugConsole);
|
||||||
|
vpnService = new VpnService(settings, *wifi, *clockService, bus);
|
||||||
|
services.add(*vpnService);
|
||||||
|
|
||||||
apps = new AppManager(launcher);
|
apps = new AppManager(launcher);
|
||||||
launcher.setManager(*apps);
|
launcher.setManager(*apps);
|
||||||
@@ -226,7 +233,7 @@ void setup() {
|
|||||||
apps->registerApp({"system", "System", false,
|
apps->registerApp({"system", "System", false,
|
||||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
||||||
apps->registerApp({"settings", "Settings", false,
|
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({"demo", "Widget demo", true, new DemoApp(bus)});
|
||||||
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)});
|
||||||
|
|
||||||
@@ -666,6 +673,7 @@ static const char* const kHelp =
|
|||||||
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
|
"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"
|
"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"
|
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
|
||||||
|
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
|
||||||
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
|
"debug 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"
|
"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"
|
"crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n"
|
||||||
@@ -725,6 +733,44 @@ static void saveScreenshot() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `vpn ...` (issue #8). Nothing here prints a key.
|
||||||
|
static void vpnCommand(const String& arg) {
|
||||||
|
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
|
||||||
|
VpnService& v = *vpnService;
|
||||||
|
if (arg == "up" || arg.startsWith("up ")) {
|
||||||
|
if (!v.configured()) return (void)console.println("vpn: error not set: copy a .conf to /vpn/wg0.conf, then `vpn import`");
|
||||||
|
uint32_t seconds = arg.length() > 3 ? constrain(arg.substring(3).toInt(), 0, 86400) : 0;
|
||||||
|
v.want(true, seconds);
|
||||||
|
if (seconds) console.printf("vpn: on for %lu s\n", (unsigned long)seconds);
|
||||||
|
else console.println("vpn: on");
|
||||||
|
} else if (arg == "down") {
|
||||||
|
v.want(false);
|
||||||
|
console.println("vpn: off");
|
||||||
|
} else if (arg == "import" || arg.startsWith("import ")) {
|
||||||
|
std::string path = arg.length() > 7 ? arg.substring(7).c_str() : "/vpn/wg0.conf";
|
||||||
|
std::string why = v.importFile(*storageService, path);
|
||||||
|
if (!why.empty()) return (void)console.printf("vpn: error %s\n", why.c_str());
|
||||||
|
console.printf("vpn: imported, through it %s. %s still holds the private key: `rm -f` it\n", net::describeWgRouting(v.config()).c_str(), path.c_str());
|
||||||
|
} else if (arg == "forget") {
|
||||||
|
v.forget();
|
||||||
|
console.println("vpn: forgotten");
|
||||||
|
} else if (arg == "status") {
|
||||||
|
if (!v.configured()) return (void)console.println("vpn: not set");
|
||||||
|
const net::WgConfig& k = v.config();
|
||||||
|
console.printf("vpn: %s%s, server %s:%u, this device %s/%d, through it %s\n", v.wanted() ? "" : "off, ", v.wanted() ? v.stateText() : "configured",
|
||||||
|
k.endpointHost.c_str(), (unsigned)k.endpointPort, net::formatIpv4(k.address).c_str(), k.prefix, net::describeWgRouting(k).c_str());
|
||||||
|
int64_t now = clockService->utcNow(), last = v.lastHandshake();
|
||||||
|
if (last > 0 && now >= last) console.printf("vpn: last handshake %ld s ago\n", (long)(now - last));
|
||||||
|
if (!v.lastError().empty()) console.printf("vpn: %s\n", v.lastError().c_str());
|
||||||
|
console.printf("vpn: start with Wi-Fi %s\n", settings.getBool(Setting::VpnAuto) ? "on" : "off");
|
||||||
|
} else if (arg == "auto on" || arg == "auto off") {
|
||||||
|
settings.setBool(Setting::VpnAuto, arg == "auto on");
|
||||||
|
console.printf("vpn: start with Wi-Fi %s\n", arg == "auto on" ? "on" : "off");
|
||||||
|
} else {
|
||||||
|
console.println("vpn: status | up [seconds] | down | import [path] | forget | auto on|off");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fn+p (issue #83): the screen as it is, dialog, help panel or Toast included. Not the page that
|
// 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.
|
// shows the Debug Console's token: a picture of it is a copy of the token in a file.
|
||||||
static void screenshotKey() {
|
static void screenshotKey() {
|
||||||
@@ -819,6 +865,7 @@ static void runCommand(String line, bool fromSerial = false) {
|
|||||||
else console.println("Not now: Setup is running");
|
else console.println("Not now: Setup is running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
|
||||||
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
|
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
|
||||||
if (line == "screenshot" || line.startsWith("screenshot ")) {
|
if (line == "screenshot" || line.startsWith("screenshot ")) {
|
||||||
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
|
uint32_t seconds = constrain(line.substring(10).toInt(), 0, 60);
|
||||||
@@ -1156,7 +1203,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",
|
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());
|
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(),
|
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");
|
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" : "");
|
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");
|
console.println(c.ntpCount ? "" : " none");
|
||||||
|
|||||||
@@ -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());
|
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
|
// 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.
|
// and then: a DHCP renewal puts DHCP's DNS back and clears the NTP slots it didn't fill.
|
||||||
void WifiService::applyServers(Why why) {
|
void WifiService::applyServers(Why why) {
|
||||||
@@ -61,7 +69,9 @@ void WifiService::applyServers(Why why) {
|
|||||||
|
|
||||||
bool wasFromSettings = dnsFromSettings_;
|
bool wasFromSettings = dnsFromSettings_;
|
||||||
dnsFromSettings_ = fixed_ || settings_.getBool(Setting::DnsAlways);
|
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));
|
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);
|
if (WiFi.dnsIP(0) != dns1 || WiFi.dnsIP(1) != dns2) WiFi.setDNS(dns1, dns2);
|
||||||
} else if (why == Why::SettingsChanged && wasFromSettings) {
|
} else if (why == Why::SettingsChanged && wasFromSettings) {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class WifiService : public Service {
|
|||||||
void ipSettingChanged(const std::string& ssid);
|
void ipSettingChanged(const std::string& ssid);
|
||||||
// The DNS or NTP settings changed: use them now.
|
// The DNS or NTP settings changed: use them now.
|
||||||
void serversChanged() { applyServers(Why::SettingsChanged); }
|
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
|
// 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.
|
// during the test brings Wi-Fi back, which a changed setting wouldn't.
|
||||||
void debugPause(bool paused) { paused_ = paused; }
|
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 paused_ = false; // Debug Builds: off for a moment, whatever the setting says
|
||||||
bool fixed_ = false; // the network in use has a Fixed address
|
bool fixed_ = false; // the network in use has a Fixed address
|
||||||
bool dnsFromSettings_ = false;
|
bool dnsFromSettings_ = false;
|
||||||
|
bool dnsHeld_ = false;
|
||||||
std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here
|
std::string ntpNames_[2]; // lwIP keeps the pointers, so the names live here
|
||||||
uint32_t serversCheckedMs_ = 0;
|
uint32_t serversCheckedMs_ = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ void statusBar(Canvas& c, const StatusInfo& info) {
|
|||||||
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
|
case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break;
|
||||||
case StatusInfo::Wifi::None: break;
|
case StatusInfo::Wifi::None: break;
|
||||||
}
|
}
|
||||||
|
switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered
|
||||||
|
case StatusInfo::Vpn::Trying: right("VPN", kMuted); break;
|
||||||
|
case StatusInfo::Vpn::Up: right("VPN", kAccent); break;
|
||||||
|
case StatusInfo::Vpn::Off: break;
|
||||||
|
}
|
||||||
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
|
switch (info.debug) { // Q190: there while the console listens, bright with someone connected
|
||||||
case StatusInfo::Debug::On: right("DBG", kMuted); break;
|
case StatusInfo::Debug::On: right("DBG", kMuted); break;
|
||||||
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
|
case StatusInfo::Debug::Client: right("DBG", kAccent); break;
|
||||||
|
|||||||
+2
-1
@@ -31,12 +31,13 @@ struct StatusInfo {
|
|||||||
enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes
|
enum class Radio { None, Listening, Packet, Sweep } radio = Radio::None; // M3, Q101: Packet flashes
|
||||||
bool capturing = false; // a LoRa Capture is recording (Q97)
|
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 Debug { Off, On, Client } debug = Debug::Off; // the Debug Console listens (ADR 0010, Q190)
|
||||||
|
enum class Vpn { Off, Trying, Up } vpn = Vpn::Off; // the WireGuard tunnel (issue #8, Q252)
|
||||||
|
|
||||||
bool operator==(const StatusInfo& o) const {
|
bool operator==(const StatusInfo& o) const {
|
||||||
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock &&
|
||||||
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose && wifi == o.wifi &&
|
||||||
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
|
wifiBars == o.wifiBars && unread == o.unread && gnss == o.gnss && gnssSatellites == o.gnssSatellites &&
|
||||||
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug;
|
tracking == o.tracking && radio == o.radio && capturing == o.capturing && debug == o.debug && vpn == o.vpn;
|
||||||
}
|
}
|
||||||
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
bool operator!=(const StatusInfo& o) const { return !(*this == o); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user