forked from colin/nix-files
sane-vpn: port away from systemd so that i can use it as an ordinary user (no sudo)
This commit is contained in:
parent
34524ea3e4
commit
bb569b1668
@ -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";
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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";
|
||||
|
@ -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 "$@"
|
||||
|
Loading…
Reference in New Issue
Block a user