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
|
let
|
||||||
declPackageSet = pkgs: {
|
declPackageSet = pkgs: {
|
||||||
packageUnwrapped = null;
|
packageUnwrapped = null;
|
||||||
@ -172,6 +172,26 @@ in
|
|||||||
autodetectCliPaths = "existing";
|
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 = {
|
"sane-scripts.which".sandbox = {
|
||||||
method = "bwrap";
|
method = "bwrap";
|
||||||
wrapperType = "wrappedDerivation";
|
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.
|
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;
|
type = types.bool;
|
||||||
description = ''
|
description = ''
|
||||||
read-only value: set based on whichever VPN has the lowest id.
|
read-only value: set based on whichever VPN has the lowest id.
|
||||||
@ -92,14 +104,14 @@ let
|
|||||||
|
|
||||||
config = {
|
config = {
|
||||||
inherit name;
|
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
|
mkVpnConfig = name: { id, dns, endpoint, publicKey, addrV4, privateKeyFile, bridgeDevice, priorityMain, priorityWgTable, priorityFwMark, fwmark, ... }: let
|
||||||
prioMain = id + 100;
|
|
||||||
prioWgTable = id + 200;
|
|
||||||
prioFwMark = id + 300;
|
|
||||||
fwmark = id + 10000;
|
|
||||||
bridgeAddrV4 = "10.20.${builtins.toString id}.1/24";
|
bridgeAddrV4 = "10.20.${builtins.toString id}.1/24";
|
||||||
in {
|
in {
|
||||||
assertions = [
|
assertions = [
|
||||||
@ -148,7 +160,7 @@ let
|
|||||||
# the bridge itself does source nat (SNAT) to rewrite the packet source address to that of the VPNs
|
# 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.
|
# -- 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!
|
# 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;
|
From = bridgeAddrV4;
|
||||||
Table = id;
|
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 -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
|
# ${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
|
in
|
||||||
{
|
{
|
||||||
|
@ -190,7 +190,7 @@ let
|
|||||||
vpn = static-nix-shell.mkBash {
|
vpn = static-nix-shell.mkBash {
|
||||||
pname = "sane-vpn";
|
pname = "sane-vpn";
|
||||||
src = ./src;
|
src = ./src;
|
||||||
pkgs = [ "coreutils-full" "gnugrep" "gnused" "sane-scripts.ip-check" "systemd" ];
|
pkgs = [ "coreutils-full" "sane-scripts.ip-check" ];
|
||||||
};
|
};
|
||||||
which = static-nix-shell.mkBash {
|
which = static-nix-shell.mkBash {
|
||||||
pname = "sane-which";
|
pname = "sane-which";
|
||||||
|
@ -1,55 +1,104 @@
|
|||||||
#!/usr/bin/env nix-shell
|
#!/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"
|
usageDescription() {
|
||||||
shift
|
echo "sane-vpn: tool to route all system internet traffic through some VPN, or just one application's"
|
||||||
region="$1"
|
echo 'and, thanks to cap_net_admin, we can do all this without superuser!'
|
||||||
shift
|
echo "however, systemd --user has poor support for capabilities, hence this here is a bespoke script instead of a service"
|
||||||
# region should be e.g. `us` or `ukr`
|
echo ""
|
||||||
|
echo "usage:"
|
||||||
get_vpns() {
|
echo "sane-vpn up REGION"
|
||||||
vpns=$(systemctl list-unit-files \
|
echo "sane-vpn down REGION"
|
||||||
| grep vpn- | cut -f 1 -d ' ' \
|
echo "sane-vpn do REGION COMMAND [COMMAND ARGS ...]"
|
||||||
| sed s'/\.service$//' \
|
echo "sane-vpn help"
|
||||||
| sed s'/^vpn-//' \
|
|
||||||
| sed s'/^ovpnd-//'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
## GLOBALS, POPULATED LATER:
|
||||||
service="$region"
|
# region, populated from CLI: should be e.g. `us` or `ukr`
|
||||||
elif systemctl -q list-unit-files "vpn-$region.service"; then
|
region=
|
||||||
service="vpn-$region.service"
|
# vpn names: populated from ~/.config/sane-vpn
|
||||||
elif systemctl -q list-unit-files "vpn-ovpnd-$region.service"; then
|
vpns=()
|
||||||
service="vpn-ovpnd-$region.service"
|
defaultVpn=
|
||||||
elif systemctl -q list-unit-files "wg-quick-$region.service"; then
|
|
||||||
service="wg-quick-$region.service"
|
# 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
|
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() {
|
usage() {
|
||||||
rc="$1"
|
rc="$1"
|
||||||
msg="$2"
|
msg="$2"
|
||||||
|
|
||||||
get_vpns
|
|
||||||
test -n "$msg" && echo "$msg"
|
test -n "$msg" && echo "$msg"
|
||||||
|
|
||||||
echo "usage:"
|
usageDescription
|
||||||
echo "sane-vpn up REGION"
|
|
||||||
echo "sane-vpn down REGION"
|
|
||||||
echo "sane-vpn do REGION COMMAND [COMMAND ARGS ...]"
|
|
||||||
echo "sane-vpn help"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "regions:"
|
echo "regions:"
|
||||||
echo "$vpns"
|
echo "$vpns"
|
||||||
@ -57,35 +106,33 @@ usage() {
|
|||||||
test -n "$rc" && exit "$rc"
|
test -n "$rc" && exit "$rc"
|
||||||
}
|
}
|
||||||
|
|
||||||
vpn_toggle() {
|
parseCli() {
|
||||||
verb="$1"
|
_oper="$1"
|
||||||
canonicalize_region
|
shift
|
||||||
test -n "$service" || usage 1 "invalid region '$region'"
|
region="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
echo before: $(sane-ip-check --no-upnp)
|
getVpns
|
||||||
sudo systemctl "$verb" "$service"
|
canonicalizeRegion
|
||||||
echo after: $(sane-ip-check --no-upnp)
|
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() {
|
parseCli "$@"
|
||||||
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
|
|
||||||
|
Loading…
Reference in New Issue
Block a user