Public Access
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
218 lines
9.5 KiB
C++
218 lines
9.5 KiB
C++
#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
|