From 3c40fa6982810659c96b2148aa1c4a02e412b8a9 Mon Sep 17 00:00:00 2001 From: Colin Date: Sat, 27 May 2023 09:57:09 +0000 Subject: [PATCH] sane-script to forward a list of ports via UPnP --- pkgs/additional/sane-scripts/default.nix | 29 ++++- .../sane-scripts/src/lib/sane_ssdp.py | 110 ++++++++++++++++++ .../sane-scripts/src/sane-ip-check-upnp | 88 +------------- .../sane-scripts/src/sane-ip-port-forward | 74 ++++++++++++ pkgs/additional/static-nix-shell/default.nix | 3 + 5 files changed, 217 insertions(+), 87 deletions(-) create mode 100644 pkgs/additional/sane-scripts/src/lib/sane_ssdp.py create mode 100755 pkgs/additional/sane-scripts/src/sane-ip-port-forward diff --git a/pkgs/additional/sane-scripts/default.nix b/pkgs/additional/sane-scripts/default.nix index cd832bfe..65453b69 100644 --- a/pkgs/additional/sane-scripts/default.nix +++ b/pkgs/additional/sane-scripts/default.nix @@ -90,11 +90,17 @@ let }; }; - # remove python scripts (we package them further below) - patchPhase = builtins.concatStringsSep - "\n" - (lib.mapAttrsToList (name: pkg: "rm ${pkg.pname}") py-scripts) - ; + patchPhase = + let + rmPy = builtins.concatStringsSep + "\n" + (lib.mapAttrsToList (name: pkg: "rm ${pkg.pname}") py-scripts) + ; + in '' + # remove python library files, and python binaries (those are packaged further below) + rm -rf lib/ + ${rmPy} + ''; installPhase = '' mkdir -p $out/bin @@ -142,6 +148,19 @@ let pname = "sane-ip-check-upnp"; src = ./src; pkgs = [ "miniupnpc" ]; + postInstall = '' + mkdir -p $out/bin/lib + cp -R lib/* $out/bin/lib/ + ''; + }; + ip-port-forward = static-nix-shell.mkPython3Bin { + pname = "sane-ip-port-forward"; + src = ./src; + pkgs = [ "miniupnpc" ]; + postInstall = '' + mkdir -p $out/bin/lib + cp -R lib/* $out/bin/lib/ + ''; }; reclaim-boot-space = static-nix-shell.mkPython3Bin { pname = "sane-reclaim-boot-space"; diff --git a/pkgs/additional/sane-scripts/src/lib/sane_ssdp.py b/pkgs/additional/sane-scripts/src/lib/sane_ssdp.py new file mode 100644 index 00000000..b86e6d48 --- /dev/null +++ b/pkgs/additional/sane-scripts/src/lib/sane_ssdp.py @@ -0,0 +1,110 @@ +# based on this minimal SSDP client: + +import logging +import socket +import struct +import subprocess + +logger = logging.getLogger(__name__) + + +MCAST_GRP = "239.255.255.250" + +class SsdpResponse: + def __init__(self, headers: "Dict[str, str]"): + self.headers = headers + + @staticmethod + def parse(msg: str) -> "Self": + headers = {} + for line in [m.strip() for m in msg.split("\r\n") if m.strip()]: + if ":" not in line: continue + sep_idx = line.find(":") + header, content = line[:sep_idx].strip(), line[sep_idx+1:].strip() + headers[header.upper()] = content + if headers: + return SsdpResponse(headers) + + def is_rootdevice(self) -> bool: + return self.headers.get("NT", "").lower() == "upnp:rootdevice" + + def location(self) -> str: + return self.headers.get("LOCATION") + + +def get_root_devices(): + listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + + listener.bind(("", 1900)) + logger.info("bound") + + mreq = struct.pack("4sl", socket.inet_aton(MCAST_GRP), socket.INADDR_ANY) + listener.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) + + root_descs = set() + while True: + packet, (host, src_port) = listener.recvfrom(2048) + logger.info(f"message from {host}") + # if host.endswith(".1"): # router + try: + msg = packet.decode("utf-8") + except: + logger.debug("failed to decode packet to string") + else: + logger.debug(msg) + resp = SsdpResponse.parse(msg) + if resp and resp.is_rootdevice(): + root_desc = resp.location() + if root_desc and root_desc not in root_descs: + root_descs.add(root_desc) + logger.info(f"root desc: {root_desc}") + yield root_desc + +def get_wan_from_location(location: str): + """ location = URI from the Location header, e.g. http://10.78.79.1:2189/rootDesc.xml """ + + # get connection [s]tatus + res = subprocess.run(["upnpc", "-u", location, "-s"], capture_output=True) + res.check_returncode() + + status = res.stdout.decode("utf-8") + logger.info(f"got status: {status}") + + for line in [l.strip() for l in status.split("\n")]: + sentinel = "ExternalIPAddress =" + if line.startswith(sentinel): + ip = line[len(sentinel):].strip() + return ip + +def get_any_wan(): + """ return (location, WAN IP) for the first device seen which has a WAN IP """ + for location in get_root_devices(): + wan = get_wan_from_location(location) + if wan: + return location, wan + +def get_lan_ip() -> str: + ips = subprocess.check_output(["hostname", "-i"]).decode("utf-8").strip().split(" ") + ips = [i for i in ips if i.startswith("10.") or i.startswith("192.168.")] + assert len(ips) == 1, ips + return ips[0] + +def forward_port(root_device: str, proto: str, port: int, reason: str, duration: int = 86400, lan_ip: str = None): + lan_ip = lan_ip or get_lan_ip() + args = [ + "upnpc", + "-u", root_device, + "-e", reason, + "-a", lan_ip, + str(port), + str(port), + proto, + str(duration), + ] + + logger.debug(f"running: {args!r}") + stdout = subprocess.check_output(args).decode("utf-8") + + logger.info(stdout) diff --git a/pkgs/additional/sane-scripts/src/sane-ip-check-upnp b/pkgs/additional/sane-scripts/src/sane-ip-check-upnp index 8c74d8d7..5056ee9c 100755 --- a/pkgs/additional/sane-scripts/src/sane-ip-check-upnp +++ b/pkgs/additional/sane-scripts/src/sane-ip-check-upnp @@ -1,94 +1,17 @@ #!/usr/bin/env nix-shell #!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p miniupnpc -# based on this minimal SSDP client: # best to run this with an external timeout. e.g. # - `timeout 60 sane-ip-check-upnp` import logging -import socket -import struct -import subprocess +import os import sys -logger = logging.getLogger(__name__) +d = os.path.dirname(__file__) +sys.path.insert(0, d) - -MCAST_GRP = "239.255.255.250" - -class SsdpResponse: - def __init__(self, headers: "Dict[str, str]"): - self.headers = headers - - @staticmethod - def parse(msg: str) -> "Self": - headers = {} - for line in [m.strip() for m in msg.split("\r\n") if m.strip()]: - if ":" not in line: continue - sep_idx = line.find(":") - header, content = line[:sep_idx].strip(), line[sep_idx+1:].strip() - headers[header.upper()] = content - if headers: - return SsdpResponse(headers) - - def is_rootdevice(self) -> bool: - return self.headers.get("NT", "").lower() == "upnp:rootdevice" - - def location(self) -> str: - return self.headers.get("LOCATION") - - -def get_root_devices(): - listener = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) - - listener.bind(("", 1900)) - logger.info("bound") - - mreq = struct.pack("4sl", socket.inet_aton(MCAST_GRP), socket.INADDR_ANY) - listener.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) - - root_descs = set() - while True: - packet, (host, src_port) = listener.recvfrom(2048) - logger.info(f"message from {host}") - # if host.endswith(".1"): # router - try: - msg = packet.decode("utf-8") - except: - logger.debug("failed to decode packet to string") - else: - logger.debug(msg) - resp = SsdpResponse.parse(msg) - if resp and resp.is_rootdevice(): - root_desc = resp.location() - if root_desc and root_desc not in root_descs: - root_descs.add(root_desc) - logger.info(f"root desc: {root_desc}") - yield root_desc - -def get_wan_from_location(location: str): - """ location = URI from the Location header, e.g. http://10.78.79.1:2189/rootDesc.xml """ - - # get connection [s]tatus - res = subprocess.run(["upnpc", "-u", location, "-s"], capture_output=True) - res.check_returncode() - - status = res.stdout.decode("utf-8") - logger.info(f"got status: {status}") - - for line in [l.strip() for l in status.split("\n")]: - sentinel = "ExternalIPAddress =" - if line.startswith(sentinel): - ip = line[len(sentinel):].strip() - return ip - -def get_any_wan(): - for location in get_root_devices(): - wan = get_wan_from_location(location) - if wan: - return wan +from lib.sane_ssdp import get_any_wan if __name__ == '__main__': logging.basicConfig() @@ -100,4 +23,5 @@ if __name__ == '__main__': logging.getLogger().setLevel(logging.DEBUG) else: raise RuntimeError(f"invalid CLI argument {arg!r}") - print(get_any_wan()) + _rootdev, wan_ip = get_any_wan() + print(wan_ip) diff --git a/pkgs/additional/sane-scripts/src/sane-ip-port-forward b/pkgs/additional/sane-scripts/src/sane-ip-port-forward new file mode 100755 index 00000000..f4bc6661 --- /dev/null +++ b/pkgs/additional/sane-scripts/src/sane-ip-port-forward @@ -0,0 +1,74 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p miniupnpc + +''' +USAGE: sane-ip-port-forward [options] [proto:port]* + +options: + -v: verbose (show info messages) + -vv: more verbose (show debug messages) + -h: show this help messages + +proto:port: + proto is `udp` or `tcp` (case insensitive) + port is any integer 1-65535 inclusive +''' + +import logging +import subprocess +import sys + +sys.path.insert(0, ".") + +from lib.sane_ssdp import get_any_wan, forward_port + +class BadCliArgs(Exception): + def __init__(self, msg: str = None): + helpstr = __doc__.strip() + if msg: + super().__init__(f"{msg}\n\n{helpstr}") + else: + super().__init__(helpstr) + +def try_parse_port(s: str): + """ + `udp:53` -> ["udp", 53] + `tcp:65535` -> ["tcp", 65535] + """ + try: + proto, portstr = s.strip().split(":") + proto, port = proto.lower(), int(portstr) + assert proto in ["tcp", "udp"] + assert 0 < port < 65536 + return proto, port + except Exception: + pass + +def parse_args(argv: "List[str]") -> "List[('udp'|'tcp', port)]": + forwards = [] + for arg in sys.argv[1:]: + if arg == "-h": + raise BadCliArgs() + if arg == "-v": + logging.getLogger().setLevel(logging.INFO) + elif arg == "-vv": + logging.getLogger().setLevel(logging.DEBUG) + elif try_parse_port(arg): + forwards.append(try_parse_port(arg)) + else: + raise BadCliArgs(f"invalid CLI argument {arg!r}") + return forwards + +if __name__ == '__main__': + logging.basicConfig() + + try: + forwards = parse_args(sys.argv) + except BadCliArgs as e: + print(e) + sys.exit(1) + + root_device, _wan = get_any_wan() + hostname = subprocess.check_output(["hostname"]).decode("utf-8").strip() + for (proto, port) in forwards: + forward_port(root_device, proto, port, f"colin-{hostname}") diff --git a/pkgs/additional/static-nix-shell/default.nix b/pkgs/additional/static-nix-shell/default.nix index 7896b649..6916b5af 100644 --- a/pkgs/additional/static-nix-shell/default.nix +++ b/pkgs/additional/static-nix-shell/default.nix @@ -53,6 +53,7 @@ in rec { ''; nativeBuildInputs = [ makeWrapper ]; installPhase = '' + runHook preInstall mkdir -p $out/bin mv ${srcPath} $out/bin/${srcPath} @@ -62,6 +63,8 @@ in rec { # add runtime dependencies to PATH wrapProgram $out/bin/${srcPath} \ --suffix PATH : ${lib.makeBinPath pkgsEnv } + + runHook postInstall ''; } // (removeAttrs attrs [ "interpreter" "interpreterName" "pkgsEnv" "pkgExprs" "srcPath" ]) );