sane-vpn: port away from systemd so that i can use it as an ordinary user (no sudo)

This commit is contained in:
Colin 2024-02-20 22:16:23 +00:00
parent 34524ea3e4
commit bb569b1668
4 changed files with 156 additions and 98 deletions

View File

@ -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";

View File

@ -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
{

View File

@ -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";

View File

@ -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 "$@"