summaryrefslogtreecommitdiff
path: root/modules/vpn
diff options
context:
space:
mode:
Diffstat (limited to 'modules/vpn')
-rw-r--r--modules/vpn/definition.nix63
-rw-r--r--modules/vpn/egress.nix108
-rw-r--r--modules/vpn/ingress.nix184
3 files changed, 355 insertions, 0 deletions
diff --git a/modules/vpn/definition.nix b/modules/vpn/definition.nix
new file mode 100644
index 0000000..0eb2ac1
--- /dev/null
+++ b/modules/vpn/definition.nix
@@ -0,0 +1,63 @@
+# definition of the network layout which supports our vpn
+# architecture.
+
+{
+ # these are the available paths which a user is allowed to take
+ # to reach a specified egress server.
+ # when a user connects to a port defined here via wireguard,
+ # the primary ingress server (us), will establish a connection with
+ # the user and the backend egress server (this time, not via wireguard,
+ # but with a specific dpi-evading protocol), and route the users packets
+ # through to the egress.
+ paths = [
+ {
+ port = 50501;
+ egress = "taupe";
+
+ info = {
+ uuid = "328c90a0-20ae-4d4c-9e54-97e9ab41c053";
+ short = "b20629b505f39194";
+
+ public = "_837k5niQBE-qmgqpZalH3cS_fAIBwv8dwMoDW1uvgk";
+ keySecret = ../../secrets/vpn/egress-key-taupe.age;
+ };
+ }
+ {
+ port = 50502;
+ egress = "taureau";
+
+ info = {
+ uuid = "826b8598-ed75-4782-9b7e-27e0e16e1141";
+ short = "8f7e9f8a3fa46bf0";
+
+ public = "HvR4iP8URERpPBM4oG1Bjfw3mIfN0MoL2x6MHlt_TUM";
+ keySecret = ../../secrets/vpn/egress-key-taureau.age;
+ };
+ }
+ ];
+
+ # there are our users who are allowed to connect to any of our "paths".
+ # their ip is always a template, with 'X' representing the path index.
+ users = {
+ mel = {
+ key = "vnZoHXapCLLUhZ8A8R5W0iJ8LpWVLve29z41kkoT0BU=";
+ ip = "10.123.X.101";
+ };
+
+ andrei = {
+ key = "qqU4uYImLfUohIwl4KBshPtTINFcs0JVALjbmwpfxRg=";
+ ip = "10.123.X.102";
+ };
+
+ sergo = {
+ key = "qbZGMNIDZFCJC6SHtlyNIlIdGWHELceXClJCcagrj2Y=";
+ ip = "10.123.X.103";
+ };
+ };
+
+ # we use a website as a "mask" for vless/reality, which will tell our peers
+ # to pretend as if they're a user and a well-known website communicating with
+ # each other, even though they know that the keys don't actually match up,
+ # it's not possible to see that on the outside.
+ mask = "microsoft.com";
+}
diff --git a/modules/vpn/egress.nix b/modules/vpn/egress.nix
new file mode 100644
index 0000000..699d107
--- /dev/null
+++ b/modules/vpn/egress.nix
@@ -0,0 +1,108 @@
+{
+ me,
+ config,
+ pkgs,
+ lib,
+ ...
+}:
+
+let
+ inherit (lib) findFirst;
+
+ # this is the https port, we use it to try to trick dpi into thinking
+ # we are just serving normal encrypted web traffic, nothing interesting! :)
+ # this does mean that our egress servers are unable to support normal www
+ # services which we put on machines by default, which is okay.
+ port = 443;
+
+ # supposedly the current gold-standard protocol for circumventing dpi!
+ # both xray (egress-side) and sing-box (ingress-side) support various
+ # other protocols, if roskomnadzor learns to sniff out vless fully.
+ protocol = "vless";
+
+ definition = import ./definition.nix;
+ inherit (definition) paths mask;
+
+ path = findFirst (
+ p: p.egress == me.name
+ ) (throw "no egress information found for this server!") paths;
+
+ xrayConfig = pkgs.writeText "xray.json" (
+ builtins.toJSON {
+ inbounds = [
+ {
+ inherit port protocol;
+
+ settings = {
+ clients = [
+ {
+ id = path.info.uuid;
+ flow = "xtls-rprx-vision";
+ }
+ ];
+ decryption = "none";
+ };
+
+ streamSettings = {
+ network = "tcp";
+ security = "reality";
+ realitySettings = {
+ show = false;
+ dest = "www.${mask}:443";
+ serverNames = [
+ "www.${mask}"
+ mask
+ ];
+ privateKey = "@PRIVATE_KEY@";
+ shortIds = [ path.info.short ];
+ };
+ };
+ }
+ ];
+
+ # and we're out!
+ outbounds = [
+ {
+ protocol = "freedom";
+ tag = "direct";
+ }
+ ];
+ }
+ );
+in
+{
+ networking.firewall.allowedTCPPorts = [ port ];
+
+ age.secrets.egress-key = {
+ file = path.info.keySecret;
+ };
+
+ # we have to make an xray config on the fly because
+ # xray does not like reading secrets from specific files,
+ # it wants them in plain-text!
+ systemd.services.generate-xray-config = {
+ before = [ "xray.service" ];
+ requiredBy = [ "xray.service" ];
+ serviceConfig = {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ };
+ script = ''
+ mkdir -p /run/xray-configuration
+ cp ${xrayConfig} /run/xray-configuration/xray.json
+
+ egress_key=$(cat ${config.age.secrets.egress-key.path})
+
+ # use sd for replacement as a fancy new tool for this
+ ${pkgs.sd}/bin/sd "@PRIVATE_KEY@" "$egress_key" /run/xray-configuration/xray.json
+
+ chown root:xray /run/xray-configuration/xray.json
+ chmod 640 /run/xray-configuration/xray.json
+ '';
+ };
+
+ services.xray = {
+ enable = true;
+ settingsFile = "/run/xray-configuration/xray.json";
+ };
+}
diff --git a/modules/vpn/ingress.nix b/modules/vpn/ingress.nix
new file mode 100644
index 0000000..d26b1ec
--- /dev/null
+++ b/modules/vpn/ingress.nix
@@ -0,0 +1,184 @@
+{
+ config,
+ lib,
+ ...
+}:
+
+let
+ inherit (lib)
+ imap0
+ attrValues
+ mergeAttrsList
+ replaceString
+ concatImapStringsSep
+ ;
+
+ definition = import ./definition.nix;
+
+ inherit (definition) paths users mask;
+
+ ownAddress = "10.123.X.1"; # ip of host running the ingress vpn (per-interface)
+
+ addressFromTemplate =
+ index: template: prefix:
+ "${replaceString "X" (toString (index + 1)) template}/${toString prefix}";
+
+ ingressName = index: "vpn-ingress${toString index}";
+ egressName = "vpn-egress0";
+
+ egressHost = name: "${name}.rnrd.eu";
+in
+{
+ boot.kernel.sysctl = {
+ "net.ipv4.ip_forward" = 1; # allow ipv4 forwarding
+ };
+
+ networking.firewall = {
+ allowedUDPPorts = map (x: x.port) paths;
+ };
+
+ age.secrets.ingress-key = {
+ file = ../../secrets/vpn/ingress-key.age;
+ };
+
+ systemd.network =
+ let
+ mkNetdev = index: path: {
+ "10-${ingressName index}" = {
+ netdevConfig = {
+ Kind = "wireguard";
+ Name = ingressName index;
+ };
+ wireguardConfig = {
+ PrivateKeyFile = config.age.secrets.ingress-key.path;
+ ListenPort = path.port;
+ };
+ wireguardPeers = map (user: {
+ wireguardPeerConfig = {
+ PublicKey = user.key;
+ AllowedIPs = [ (addressFromTemplate index user.ip 32) ];
+ };
+ }) (attrValues users);
+ };
+ };
+
+ mkNetwork = index: path: {
+ "10-${ingressName index}" = {
+ name = ingressName index;
+ address = [ (addressFromTemplate index ownAddress 24) ];
+ routingPolicyRules = [
+ {
+ routingPolicyRuleConfig = {
+ IncomingInterface = ingressName index;
+ Table = 100;
+ };
+ }
+ ];
+ };
+ };
+
+ ingressNetdevs = imap0 mkNetdev paths;
+
+ ingressNetworks = imap0 mkNetwork paths;
+ egressNetworks = [
+ {
+ "20-${egressName}" = {
+ name = egressName;
+ linkConfig.ActivationPolicy = "up";
+ routes = [
+ {
+ routeConfig = {
+ Destination = "0.0.0.0/0";
+ Table = 100;
+ Scope = "link";
+ };
+ }
+ ];
+ };
+ }
+ ];
+ in
+ {
+ netdevs = mergeAttrsList ingressNetdevs;
+ networks = mergeAttrsList (ingressNetworks ++ egressNetworks);
+ };
+
+ # allow forwarding packets between egress and ingress, but avoid any snat,
+ # ip should always keep it's origin form, for correct egress routing.
+ networking.nftables.ruleset =
+ let
+ ingressInterfaces = concatImapStringsSep "\", \"" (i: _: ingressName (i - 1)) paths;
+ in
+ ''
+ table inet filter {
+ chain forward {
+ type filter hook forward priority 0; policy drop;
+
+ iifname { "${ingressInterfaces}" } oifname "${egressName}" accept
+ iifname "${egressName}" oifname { "${ingressInterfaces}" } accept
+ }
+ }
+ '';
+
+ # sing-box is a vpn client supporting various protocols which will allow us
+ # to configure it in whichever way we want to avoid russian dpi.
+ # in this case, our communications crossing the borders are relying on vless.
+ services.sing-box =
+ let
+ inboundName = "vpn-in";
+ outboundName = egress: "vpn-out-${egress}";
+ in
+ {
+ enable = true;
+ settings = {
+ inbounds = [
+ {
+ type = "tun";
+ tag = inboundName;
+ interface_name = egressName;
+ inet4_address = "10.123.255.1/30";
+ auto_route = false; # we route manually
+ strict_route = false;
+ endpoint_independent_nat = true;
+ }
+ ];
+
+ outbounds = map (path: {
+ type = "vless";
+ flow = "xtls-rprx-vision";
+
+ server = egressHost path.egress;
+ server_port = 443;
+
+ tag = outboundName path.egress;
+ uuid = path.info.uuid;
+
+ tls = {
+ enabled = true;
+ server_name = "www.${mask}";
+
+ utls = {
+ enabled = true;
+ fingerprint = "chrome";
+ };
+
+ reality = {
+ enabled = true;
+ public_key = path.info.public;
+ short_id = path.info.short;
+ };
+ };
+ }) paths;
+
+ route = {
+ rules = imap0 (index: path: {
+ inbound = inboundName;
+ source_ip_cidr = [ (addressFromTemplate index "10.123.X.0" 24) ];
+ outbound = outboundName path.egress;
+ }) paths;
+
+ auto_detect_interface = true;
+ };
+ };
+ };
+}