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:
@@ -24,6 +24,7 @@ const RowDef kRows[] = {
|
||||
{Row::Coordinates, Kind::Toggle, "Coordinates"},
|
||||
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
|
||||
{Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"},
|
||||
{Row::Vpn, Kind::Page, "VPN"},
|
||||
{Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"},
|
||||
};
|
||||
|
||||
@@ -86,6 +87,7 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
|
||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
||||
case Row::Vpn: return settings_.getString(Setting::VpnConfig).empty() ? "Not set" : settings_.getBool(Setting::VpnAuto) ? "With Wi-Fi" : "By hand";
|
||||
case Row::DebugConsole: return settings_.getBool(Setting::DebugConsole) ? "On" : "Off";
|
||||
default: return "";
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace roro {
|
||||
// values, choice lists and validation messages. Rendering and navigation live in the App.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, DebugConsole, About };
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page };
|
||||
|
||||
explicit SettingsMenu(Settings& settings) : settings_(settings) {}
|
||||
|
||||
@@ -301,6 +301,12 @@ inline constexpr KeyHelp kViewerImage[] = {
|
||||
{"Tab", "the file as hex"},
|
||||
};
|
||||
|
||||
// vpn: Settings, VPN
|
||||
inline constexpr KeyHelp kVpn[] = {
|
||||
{"Enter", "switch, import, forget"},
|
||||
{"; .", "up, down"},
|
||||
};
|
||||
|
||||
// notes: Notes, the list
|
||||
inline constexpr KeyHelp kNotes[] = {
|
||||
{"; .", "up, down"},
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#include "wg_config.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "ipv4.h"
|
||||
|
||||
namespace roro::net {
|
||||
|
||||
namespace {
|
||||
std::string trim(const std::string& s) {
|
||||
size_t a = s.find_first_not_of(" \t\r"), b = s.find_last_not_of(" \t\r");
|
||||
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
|
||||
}
|
||||
std::string lower(std::string s) {
|
||||
for (char& c : s)
|
||||
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c + 32);
|
||||
return s;
|
||||
}
|
||||
bool number(const std::string& s, long& out, long max) {
|
||||
if (s.empty() || s.size() > 6) return false;
|
||||
out = 0;
|
||||
for (char c : s) {
|
||||
if (c < '0' || c > '9') return false;
|
||||
out = out * 10 + (c - '0');
|
||||
}
|
||||
return out <= max;
|
||||
}
|
||||
// "10.9.0.2/24", or an address alone (then /32). False for anything else, IPv6 included.
|
||||
bool range(const std::string& text, WgRange& out) {
|
||||
size_t slash = text.find('/');
|
||||
long prefix = 32;
|
||||
if (slash != std::string::npos && !number(text.substr(slash + 1), prefix, 32)) return false;
|
||||
if (!parseIpv4(text.substr(0, slash), out.address)) return false;
|
||||
out.prefix = static_cast<int>(prefix);
|
||||
return true;
|
||||
}
|
||||
template <typename Each>
|
||||
void eachItem(const std::string& list, Each each) {
|
||||
size_t at = 0;
|
||||
while (at <= list.size()) {
|
||||
size_t comma = list.find(',', at);
|
||||
if (comma == std::string::npos) comma = list.size();
|
||||
std::string item = trim(list.substr(at, comma - at));
|
||||
if (!item.empty()) each(item);
|
||||
at = comma + 1;
|
||||
}
|
||||
}
|
||||
bool inRange(uint32_t address, const WgRange& r) { return (address & maskOf(r.prefix)) == (r.address & maskOf(r.prefix)); }
|
||||
} // namespace
|
||||
|
||||
bool validWgKey(const std::string& key) {
|
||||
if (key.size() != 44 || key[43] != '=') return false;
|
||||
for (size_t i = 0; i < 43; i++) {
|
||||
char c = key[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/')) return false;
|
||||
}
|
||||
// 43 characters carry 258 bits: the last one's two low bits belong to no byte and are zero.
|
||||
static const std::string kLast = "AEIMQUYcgkosw048";
|
||||
return kLast.find(key[42]) != std::string::npos;
|
||||
}
|
||||
|
||||
std::string parseWgConf(const std::string& text, WgConfig& out) {
|
||||
WgConfig c;
|
||||
enum { None, Interface, Peer, OtherPeer } section = None;
|
||||
bool hasAddress = false, hasEndpoint = false, hasKeepalive = false;
|
||||
int lineNo = 0;
|
||||
std::string problem;
|
||||
auto fail = [&](const std::string& what) {
|
||||
if (problem.empty()) problem = "line " + std::to_string(lineNo) + ": " + what;
|
||||
};
|
||||
for (size_t at = 0; at <= text.size() && problem.empty();) {
|
||||
size_t end = text.find('\n', at);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
std::string line = text.substr(at, end - at);
|
||||
at = end + 1;
|
||||
lineNo++;
|
||||
size_t hash = line.find_first_of("#;");
|
||||
if (hash != std::string::npos) line.resize(hash);
|
||||
line = trim(line);
|
||||
if (line.empty()) continue;
|
||||
if (line[0] == '[') {
|
||||
std::string name = lower(line);
|
||||
if (name == "[interface]") section = Interface;
|
||||
else if (name == "[peer]") section = section == Peer || section == OtherPeer ? OtherPeer : Peer;
|
||||
else fail("a section this doesn't know");
|
||||
if (section == OtherPeer) fail("a second peer: this device has one tunnel to one peer");
|
||||
continue;
|
||||
}
|
||||
size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) {
|
||||
fail("not a setting");
|
||||
continue;
|
||||
}
|
||||
std::string key = lower(trim(line.substr(0, eq))), value = trim(line.substr(eq + 1));
|
||||
long n = 0;
|
||||
if (section == Interface) {
|
||||
if (key == "privatekey") {
|
||||
if (!validWgKey(value)) fail("PrivateKey isn't a key");
|
||||
c.privateKey = value;
|
||||
} else if (key == "address") {
|
||||
eachItem(value, [&](const std::string& item) {
|
||||
WgRange r;
|
||||
if (!hasAddress && range(item, r)) {
|
||||
c.address = r.address;
|
||||
c.prefix = r.prefix;
|
||||
hasAddress = true;
|
||||
}
|
||||
});
|
||||
if (!hasAddress) fail("Address has no IPv4 address");
|
||||
} else if (key == "dns") {
|
||||
int count = 0;
|
||||
eachItem(value, [&](const std::string& item) { // names and IPv6 servers are left out
|
||||
uint32_t ip;
|
||||
if (count < 2 && parseIpv4(item, ip)) c.dns[count++] = ip;
|
||||
});
|
||||
} else if (key == "mtu") {
|
||||
if (!number(value, n, 1500) || n < 576) fail("MTU must be 576 to 1500");
|
||||
c.mtu = static_cast<int>(n);
|
||||
} else if (key == "listenport") {
|
||||
if (!number(value, n, 65535)) fail("ListenPort must be a port");
|
||||
c.listenPort = static_cast<uint16_t>(n);
|
||||
} // Table, PostUp and the rest mean nothing here
|
||||
} else if (section == Peer) {
|
||||
if (key == "publickey") {
|
||||
if (!validWgKey(value)) fail("PublicKey isn't a key");
|
||||
c.peerKey = value;
|
||||
} else if (key == "presharedkey") {
|
||||
if (!validWgKey(value)) fail("PresharedKey isn't a key");
|
||||
c.presharedKey = value;
|
||||
} else if (key == "endpoint") {
|
||||
size_t colon = value.rfind(':');
|
||||
if (value.empty() || value[0] == '[') fail("an IPv6 Endpoint: IPv4 or a name only");
|
||||
else if (colon == std::string::npos || colon == 0 || !number(value.substr(colon + 1), n, 65535) || n == 0) fail("Endpoint must be host:port");
|
||||
else if (value.find_first_of(" \t,/") != std::string::npos || colon > 253) fail("Endpoint must be host:port");
|
||||
else {
|
||||
c.endpointHost = value.substr(0, colon);
|
||||
c.endpointPort = static_cast<uint16_t>(n);
|
||||
hasEndpoint = true;
|
||||
}
|
||||
} else if (key == "allowedips") {
|
||||
eachItem(value, [&](const std::string& item) {
|
||||
WgRange r;
|
||||
if (item.find(':') != std::string::npos) return; // IPv6: not routed here
|
||||
if (!range(item, r)) return fail("AllowedIPs has something that isn't an address range");
|
||||
if (c.allowedCount == WgConfig::kMaxRanges) return fail("AllowedIPs: four IPv4 ranges at most");
|
||||
r.address &= maskOf(r.prefix);
|
||||
c.allowed[c.allowedCount++] = r;
|
||||
});
|
||||
} else if (key == "persistentkeepalive") {
|
||||
if (lower(value) == "off") n = 0;
|
||||
else if (!number(value, n, 65535)) fail("PersistentKeepalive must be seconds");
|
||||
c.keepalive = static_cast<int>(n);
|
||||
hasKeepalive = true;
|
||||
}
|
||||
} else if (section == None) {
|
||||
fail("a setting before [Interface]");
|
||||
}
|
||||
}
|
||||
(void)hasKeepalive;
|
||||
if (!problem.empty()) return problem;
|
||||
if (c.privateKey.empty()) return "no PrivateKey under [Interface]";
|
||||
if (!hasAddress) return "no Address under [Interface]";
|
||||
if (c.peerKey.empty()) return "no PublicKey under [Peer]";
|
||||
if (!hasEndpoint) return "no Endpoint under [Peer]";
|
||||
if (!c.allowedCount) return "no IPv4 range in AllowedIPs";
|
||||
out = c;
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string toWgConf(const WgConfig& c) {
|
||||
std::string s = "[Interface]\nPrivateKey = " + c.privateKey + "\nAddress = " + formatIpv4(c.address) + "/" + std::to_string(c.prefix) + "\n";
|
||||
if (c.dns[0]) s += "DNS = " + formatIpv4(c.dns[0]) + (c.dns[1] ? ", " + formatIpv4(c.dns[1]) : "") + "\n";
|
||||
if (c.mtu) s += "MTU = " + std::to_string(c.mtu) + "\n";
|
||||
if (c.listenPort) s += "ListenPort = " + std::to_string(c.listenPort) + "\n";
|
||||
s += "[Peer]\nPublicKey = " + c.peerKey + "\n";
|
||||
if (!c.presharedKey.empty()) s += "PresharedKey = " + c.presharedKey + "\n";
|
||||
s += "Endpoint = " + c.endpointHost + ":" + std::to_string(c.endpointPort) + "\nAllowedIPs = ";
|
||||
for (int i = 0; i < c.allowedCount; i++) s += (i ? ", " : "") + formatIpv4(c.allowed[i].address) + "/" + std::to_string(c.allowed[i].prefix);
|
||||
s += "\nPersistentKeepalive = " + std::to_string(c.keepalive) + "\n";
|
||||
return s;
|
||||
}
|
||||
|
||||
WgRouting routingOf(const WgConfig& c) {
|
||||
WgRouting r;
|
||||
for (int i = 0; i < c.allowedCount; i++)
|
||||
if (c.allowed[i].prefix == 0) r.full = true;
|
||||
if (r.full) return r;
|
||||
// The widest allowed range this device's own address is in is the interface's subnet; with
|
||||
// none, the Address line's own.
|
||||
r.prefix = c.prefix;
|
||||
bool found = false;
|
||||
for (int i = 0; i < c.allowedCount; i++)
|
||||
if (inRange(c.address, c.allowed[i]) && (!found || c.allowed[i].prefix < r.prefix)) {
|
||||
r.prefix = c.allowed[i].prefix;
|
||||
found = true;
|
||||
}
|
||||
WgRange subnet{c.address, r.prefix};
|
||||
for (int i = 0; i < c.allowedCount; i++)
|
||||
if (c.allowed[i].prefix < r.prefix || !inRange(c.allowed[i].address, subnet)) r.unreachable++;
|
||||
return r;
|
||||
}
|
||||
|
||||
bool wgReaches(const WgConfig& c, uint32_t address) {
|
||||
WgRouting r = routingOf(c);
|
||||
if (r.full) return true;
|
||||
return inRange(address, WgRange{c.address, r.prefix});
|
||||
}
|
||||
|
||||
std::string describeWgRouting(const WgConfig& c) {
|
||||
WgRouting r = routingOf(c);
|
||||
if (r.full) return "everything";
|
||||
std::string s = formatIpv4(c.address & maskOf(r.prefix)) + "/" + std::to_string(r.prefix);
|
||||
if (r.unreachable) s += ", not " + std::to_string(r.unreachable) + " other range" + (r.unreachable > 1 ? "s" : "");
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace roro::net
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// A WireGuard tunnel's configuration (issue #8, N1 Q243-Q253): read from the standard `.conf` a
|
||||
// server's owner hands out, checked, and written back in a tidy form for the device's settings.
|
||||
// One peer, IPv4. The keys are never put in a message: errors name the line and the field.
|
||||
namespace roro::net {
|
||||
|
||||
struct WgRange {
|
||||
uint32_t address = 0;
|
||||
int prefix = 0;
|
||||
};
|
||||
|
||||
struct WgConfig {
|
||||
static constexpr int kMaxRanges = 4;
|
||||
|
||||
std::string privateKey, peerKey, presharedKey; // base64, as in the file; the last may be empty
|
||||
uint32_t address = 0; // the tunnel's address on this device
|
||||
int prefix = 32;
|
||||
uint32_t dns[2] = {0, 0};
|
||||
int mtu = 0; // 0: WireGuard's 1420
|
||||
uint16_t listenPort = 0; // 0: any; a fixed one lets the peer be the one that calls
|
||||
std::string endpointHost;
|
||||
uint16_t endpointPort = 51820;
|
||||
WgRange allowed[kMaxRanges];
|
||||
int allowedCount = 0;
|
||||
int keepalive = 25; // seconds; what the file says, or 25: this device is always behind a NAT
|
||||
};
|
||||
|
||||
// "" and `out` filled, or why the file can't be used ("line 7: ...").
|
||||
std::string parseWgConf(const std::string& text, WgConfig& out);
|
||||
// The same configuration as a `.conf` again: what the settings keep.
|
||||
std::string toWgConf(const WgConfig& config);
|
||||
bool validWgKey(const std::string& key); // 32 bytes in base64
|
||||
|
||||
// What can go through the tunnel. The network stack routes by an interface's own subnet or by
|
||||
// default, nothing finer: so either everything goes through it (AllowedIPs has 0.0.0.0/0), or the
|
||||
// one subnet this device's tunnel address is in. Ranges that are neither can't be reached, and
|
||||
// the user is told how many.
|
||||
struct WgRouting {
|
||||
bool full = false; // the tunnel is the default route
|
||||
int prefix = 32; // of the tunnel interface, when not full
|
||||
int unreachable = 0; // allowed ranges outside it
|
||||
};
|
||||
WgRouting routingOf(const WgConfig& config);
|
||||
bool wgReaches(const WgConfig& config, uint32_t address); // would a packet to this address go through it?
|
||||
|
||||
// For the screen and the console: never a key.
|
||||
std::string describeWgRouting(const WgConfig& config);
|
||||
|
||||
} // namespace roro::net
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "debug_auth.h"
|
||||
#include "ipv4.h"
|
||||
#include "wg_config.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
@@ -45,6 +46,8 @@ const Definition kDefinitions[] = {
|
||||
{"debug_on", Kind::Bool, 0, nullptr, 0, 1}, // off: nothing listens until the owner says so (Q189)
|
||||
{"debug_token", Kind::String, 0, "", 0, 64}, // empty, or a valid token
|
||||
{"help_told", Kind::Bool, 0, nullptr, 0, 1},
|
||||
{"vpn_config", Kind::String, 0, "", 0, 900}, // empty, or a .conf that parses
|
||||
{"vpn_auto", Kind::Bool, 0, nullptr, 0, 1},
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
@@ -103,6 +106,10 @@ bool Settings::validString(Setting s, const std::string& value) const {
|
||||
if (s == Setting::Ntp1) return net::validHost(value);
|
||||
if (s == Setting::Ntp2) return value.empty() || net::validHost(value);
|
||||
if (s == Setting::DebugToken) return value.empty() || debug::validToken(value);
|
||||
if (s == Setting::VpnConfig) {
|
||||
net::WgConfig config;
|
||||
return value.empty() || net::parseWgConf(value, config).empty();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ enum class Setting : uint8_t {
|
||||
DebugConsole, // bool: the Debug Console listens on Wi-Fi (ADR 0010, Q189: off unless switched on)
|
||||
DebugToken, // string: its token, tidied (debug_auth.h); empty until the console is first switched on
|
||||
HelpTold, // bool: this device has been told about the help key once (issue #69, Q201)
|
||||
VpnConfig, // string: the WireGuard tunnel as a .conf (wg_config.h), private key included: never shown (issue #8)
|
||||
VpnAuto, // bool: the tunnel starts whenever Wi-Fi is connected (Q247: off unless switched on)
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user