diff --git a/hosts/common/programs/sane-scripts.nix b/hosts/common/programs/sane-scripts.nix index f6bf2eaf..9411b707 100644 --- a/hosts/common/programs/sane-scripts.nix +++ b/hosts/common/programs/sane-scripts.nix @@ -1,4 +1,4 @@ -{ ... }: +{ config, lib, ... }: let declPackageSet = pkgs: { packageUnwrapped = null; @@ -172,6 +172,26 @@ in autodetectCliPaths = "existing"; }; + "sane-scripts.vpn".fs = lib.foldl' + (acc: vpn: + let + vpnCfg = config.sane.vpn."${vpn}"; + in acc // { + ".config/sane-vpn/vpns/${vpn}".symlink.text = '' + id=${builtins.toString vpnCfg.id} + fwmark=${builtins.toString vpnCfg.fwmark} + priorityMain=${builtins.toString vpnCfg.priorityMain} + priorityFwMark=${builtins.toString vpnCfg.priorityFwMark} + bridgeDevice=${vpnCfg.bridgeDevice} + dns=(${lib.concatStringsSep " " vpnCfg.dns}) + ''; + } // (lib.optionalAttrs vpnCfg.isDefault { + ".config/sane-vpn/default".symlink.text = vpn; + }) + ) + {} + (builtins.attrNames config.sane.vpn); + "sane-scripts.which".sandbox = { method = "bwrap"; wrapperType = "wrappedDerivation"; diff --git a/modules/vpn.nix b/modules/vpn.nix index 1ff0e120..77f3af2e 100644 --- a/modules/vpn.nix +++ b/modules/vpn.nix @@ -38,7 +38,19 @@ let lowest number = default VPN to use when no other is specified, or when multiple are enabled in the same circumstance. ''; }; - default = mkOption { + fwmark = mkOption { + type = types.int; + }; + priorityMain = mkOption { + type = types.int; + }; + priorityWgTable = mkOption { + type = types.int; + }; + priorityFwMark = mkOption { + type = types.int; + }; + isDefault = mkOption { type = types.bool; description = '' read-only value: set based on whichever VPN has the lowest id. @@ -92,14 +104,14 @@ let config = { inherit name; - default = builtins.all (other: config.id <= other.id) (builtins.attrValues cfg); + isDefault = builtins.all (other: config.id <= other.id) (builtins.attrValues cfg); + fwmark = config.id + 10000; + priorityMain = config.id + 100; + priorityWgTable = config.id + 200; + priorityFwMark = config.id + 300; }; }); - mkVpnConfig = name: { id, dns, endpoint, publicKey, addrV4, privateKeyFile, bridgeDevice, ... }: let - prioMain = id + 100; - prioWgTable = id + 200; - prioFwMark = id + 300; - fwmark = id + 10000; + mkVpnConfig = name: { id, dns, endpoint, publicKey, addrV4, privateKeyFile, bridgeDevice, priorityMain, priorityWgTable, priorityFwMark, fwmark, ... }: let bridgeAddrV4 = "10.20.${builtins.toString id}.1/24"; in { assertions = [ @@ -148,7 +160,7 @@ let # the bridge itself does source nat (SNAT) to rewrite the packet source address to that of the VPNs # -- but that happens in POSTROUTING: *after* the kernel decides which interface to give the packet to next. # therefore, we have to route here based on the packet's address as it is in PREROUTING, i.e. the bridge address. weird! - Priority = prioWgTable; + Priority = priorityWgTable; From = bridgeAddrV4; Table = id; }; @@ -211,27 +223,6 @@ let # ${iptables}/bin/iptables -t mangle -I PREROUTING 1 -i ${name} -m mark --mark 0 -j CONNMARK --restore-mark # ${iptables}/bin/iptables -t mangle -A POSTROUTING -o ${name} -m mark --mark ${builtins.toString id} -j CONNMARK --save-mark # ''; - - systemd.services."vpn-${name}" = { - path = with pkgs; [ iproute2 ]; - serviceConfig = let - mkScript = verb: pkgs.writeShellScript "vpn-${name}-${verb}" '' - # first, allow all non-default routes (prefix-length != 0) a chance to route the packet. - # - this allows the wireguard tunnel itself to pass traffic via our LAN gateway. - # - incidentally, it allows traffic to LAN devices and other machine-local or virtual networks. - ip rule ${verb} from all lookup main suppress_prefixlength 0 priority ${builtins.toString prioMain} - # if packet hasn't gone through the wg device yet (fwmark), then move it to the table which will cause it to. - ip rule ${verb} not from all fwmark ${builtins.toString fwmark} lookup ${builtins.toString id} priority ${builtins.toString prioFwMark} - ''; - in { - ExecStart = ''${mkScript "add"}''; - ExecStop = ''${mkScript "del"}''; - Type = "oneshot"; - RemainAfterExit = true; - Restart = "on-failure"; - # it'd be nice if this could depend on the network device we just declared, but there seems no way to do that except via 3rd-party tooling. - }; - }; }; in { diff --git a/pkgs/additional/sane-scripts/default.nix b/pkgs/additional/sane-scripts/default.nix index c46b319f..c7697ba9 100644 --- a/pkgs/additional/sane-scripts/default.nix +++ b/pkgs/additional/sane-scripts/default.nix @@ -190,7 +190,7 @@ let vpn = static-nix-shell.mkBash { pname = "sane-vpn"; src = ./src; - pkgs = [ "coreutils-full" "gnugrep" "gnused" "sane-scripts.ip-check" "systemd" ]; + pkgs = [ "coreutils-full" "sane-scripts.ip-check" ]; }; which = static-nix-shell.mkBash { pname = "sane-which"; diff --git a/pkgs/additional/sane-scripts/src/sane-vpn b/pkgs/additional/sane-scripts/src/sane-vpn index 2b7b760b..254388f0 100755 --- a/pkgs/additional/sane-scripts/src/sane-vpn +++ b/pkgs/additional/sane-scripts/src/sane-vpn @@ -1,55 +1,104 @@ #!/usr/bin/env nix-shell -#!nix-shell -i bash -p coreutils-full -p gnugrep -p gnused -p sane-scripts.ip-check -p systemd +#!nix-shell -i bash -p coreutils-full -p sane-scripts.ip-check -oper="$1" -shift -region="$1" -shift -# region should be e.g. `us` or `ukr` - -get_vpns() { - vpns=$(systemctl list-unit-files \ - | grep vpn- | cut -f 1 -d ' ' \ - | sed s'/\.service$//' \ - | sed s'/^vpn-//' \ - | sed s'/^ovpnd-//' - ) +usageDescription() { + echo "sane-vpn: tool to route all system internet traffic through some VPN, or just one application's" + echo 'and, thanks to cap_net_admin, we can do all this without superuser!' + echo "however, systemd --user has poor support for capabilities, hence this here is a bespoke script instead of a service" + echo "" + echo "usage:" + echo "sane-vpn up REGION" + echo "sane-vpn down REGION" + echo "sane-vpn do REGION COMMAND [COMMAND ARGS ...]" + echo "sane-vpn help" } -canonicalize_region() { - if [ "$region" = "default" ]; then - # TODO: don't special-case this, but e.g. grab whichever VPN has the lowest `ip rule` priority. - region="us" - fi - if networkctl list "br-$region"; then - bridge="br-$region" - elif networkctl list "br-ovpnd-$region"; then - bridge="br-ovpnd-$region" - fi - if systemctl -q list-unit-files "$region"; then - service="$region" - elif systemctl -q list-unit-files "vpn-$region.service"; then - service="vpn-$region.service" - elif systemctl -q list-unit-files "vpn-ovpnd-$region.service"; then - service="vpn-ovpnd-$region.service" - elif systemctl -q list-unit-files "wg-quick-$region.service"; then - service="wg-quick-$region.service" +## GLOBALS, POPULATED LATER: +# region, populated from CLI: should be e.g. `us` or `ukr` +region= +# vpn names: populated from ~/.config/sane-vpn +vpns=() +defaultVpn= + +# loaded from a specific ~/.config/sane-vpn/vpns profile +id= +fwmark= +prioMain= +prioFwMark= +bridgeDevice= +dns=() + +debug() { + if [ -n "$SANE_VPN_DEBUG" ]; then + printf "%s\n" "$@" fi } +# load VPN names from disk +getVpns() { + vpns=$(ls ~/.config/sane-vpn/vpns) + defaultVpn=$(cat ~/.config/sane-vpn/default) + debug "default vpn: $defaultVpn" +} + +# load a specific VPN profile, `"$1"` +sourceVpn() { + # populates: + # - id + # - fwmark + # - prioMain + # - prioFwMark + # - bridgeDevice + # - dns + debug "sourcing: ~/.config/sane-vpn/vpns/$1" + # TODO: don't blindly source this, but parse explicitly as `K=V` + source ~/.config/sane-vpn/vpns/$1 +} + +canonicalizeRegion() { + if [ -n "$region" ] || [ "$region" = "default" ]; then + debug "canonicalizing region to $defaultVpn" + region="$defaultVpn" + fi +} + +vpnToggle() { + verb="$1" + + debug "vpnToggle with:" + debug " id='$id'" + debug " fwmark='$fwmark'" + debug " priorityMain='$priorityMain'" + debug " priorityFwMark='$priorityFwMark'" + + echo before: $(sane-ip-check --no-upnp) + + # first, allow all non-default routes (prefix-length != 0) a chance to route the packet. + # - this allows the wireguard tunnel itself to pass traffic via our LAN gateway. + # - incidentally, it allows traffic to LAN devices and other machine-local or virtual networks. + ip rule "$verb" from all lookup main suppress_prefixlength 0 priority "$priorityMain" + # if packet hasn't gone through the wg device yet (fwmark), then move it to the table which will cause it to. + ip rule "$verb" not from all fwmark "$fwmark" lookup "$id" priority "$priorityFwMark" + + echo after: $(sane-ip-check --no-upnp) +} + +vpnDo() { + debug "vpnDo with:" + debug " bridgeDevice='$bridgeDevice'" + debug " dns='$dns'" + # TODO: switch to bwrap, or `sane-sandboxed`! + firejail --noprofile --net="$bridgeDevice" --dns="$dns" "$@" +} + usage() { rc="$1" msg="$2" - get_vpns test -n "$msg" && echo "$msg" - echo "usage:" - echo "sane-vpn up REGION" - echo "sane-vpn down REGION" - echo "sane-vpn do REGION COMMAND [COMMAND ARGS ...]" - echo "sane-vpn help" + usageDescription echo "" echo "regions:" echo "$vpns" @@ -57,35 +106,33 @@ usage() { test -n "$rc" && exit "$rc" } -vpn_toggle() { - verb="$1" - canonicalize_region - test -n "$service" || usage 1 "invalid region '$region'" +parseCli() { + _oper="$1" + shift + region="$1" + shift - echo before: $(sane-ip-check --no-upnp) - sudo systemctl "$verb" "$service" - echo after: $(sane-ip-check --no-upnp) + getVpns + canonicalizeRegion + sourceVpn "$region" + + case "$_oper" in + (up) + vpnToggle add + ;; + (down) + vpnToggle del + ;; + (do) + vpnDo "$@" + ;; + (--help|help|"") + usage 0 + ;; + (*) + usage 1 "invalid operation '$_oper'" + ;; + esac } -vpn_do() { - canonicalize_region - test -n "$bridge" || usage 1 "invalid or unsupported region '$region'" - - # this is nasty. `networkctl --json=pretty` gives json output that can be consumed with jq, - # but it converts the DNS server to octets ([10, 78, 79, 1]), which i would have to reassemble - dns=$(networkctl status "$bridge" | grep 'DNS:' | sed 's/ *DNS: //') - - firejail --noprofile --net="$bridge" --dns="$dns" "$@" -} - -if [ "$oper" == up ]; then - vpn_toggle start -elif [ "$oper" == down ]; then - vpn_toggle stop -elif [ "$oper" == do ]; then - vpn_do "$@" -elif [ "$oper" == help ] || [ "$oper" == --help ] || [ -z "$oper" ]; then - usage 0 -else - usage 1 "invalid operation '$oper'" -fi +parseCli "$@"