sane-script to forward a list of ports via UPnP
This commit is contained in:
parent
c1ddddddc0
commit
3c40fa6982
|
@ -90,11 +90,17 @@ let
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# remove python scripts (we package them further below)
|
patchPhase =
|
||||||
patchPhase = builtins.concatStringsSep
|
let
|
||||||
"\n"
|
rmPy = builtins.concatStringsSep
|
||||||
(lib.mapAttrsToList (name: pkg: "rm ${pkg.pname}") py-scripts)
|
"\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 = ''
|
installPhase = ''
|
||||||
mkdir -p $out/bin
|
mkdir -p $out/bin
|
||||||
|
@ -142,6 +148,19 @@ let
|
||||||
pname = "sane-ip-check-upnp";
|
pname = "sane-ip-check-upnp";
|
||||||
src = ./src;
|
src = ./src;
|
||||||
pkgs = [ "miniupnpc" ];
|
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 {
|
reclaim-boot-space = static-nix-shell.mkPython3Bin {
|
||||||
pname = "sane-reclaim-boot-space";
|
pname = "sane-reclaim-boot-space";
|
||||||
|
|
110
pkgs/additional/sane-scripts/src/lib/sane_ssdp.py
Normal file
110
pkgs/additional/sane-scripts/src/lib/sane_ssdp.py
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
# based on this minimal SSDP client: <https://gist.github.com/schlamar/2428250>
|
||||||
|
|
||||||
|
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)
|
|
@ -1,94 +1,17 @@
|
||||||
#!/usr/bin/env nix-shell
|
#!/usr/bin/env nix-shell
|
||||||
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p miniupnpc
|
#!nix-shell -i python3 -p "python3.withPackages (ps: [ ])" -p miniupnpc
|
||||||
|
|
||||||
# based on this minimal SSDP client: <https://gist.github.com/schlamar/2428250>
|
|
||||||
# best to run this with an external timeout. e.g.
|
# best to run this with an external timeout. e.g.
|
||||||
# - `timeout 60 sane-ip-check-upnp`
|
# - `timeout 60 sane-ip-check-upnp`
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import os
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
d = os.path.dirname(__file__)
|
||||||
|
sys.path.insert(0, d)
|
||||||
|
|
||||||
|
from lib.sane_ssdp import get_any_wan
|
||||||
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
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig()
|
logging.basicConfig()
|
||||||
|
@ -100,4 +23,5 @@ if __name__ == '__main__':
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"invalid CLI argument {arg!r}")
|
raise RuntimeError(f"invalid CLI argument {arg!r}")
|
||||||
print(get_any_wan())
|
_rootdev, wan_ip = get_any_wan()
|
||||||
|
print(wan_ip)
|
||||||
|
|
74
pkgs/additional/sane-scripts/src/sane-ip-port-forward
Executable file
74
pkgs/additional/sane-scripts/src/sane-ip-port-forward
Executable file
|
@ -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}")
|
|
@ -53,6 +53,7 @@ in rec {
|
||||||
'';
|
'';
|
||||||
nativeBuildInputs = [ makeWrapper ];
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
mkdir -p $out/bin
|
mkdir -p $out/bin
|
||||||
mv ${srcPath} $out/bin/${srcPath}
|
mv ${srcPath} $out/bin/${srcPath}
|
||||||
|
|
||||||
|
@ -62,6 +63,8 @@ in rec {
|
||||||
# add runtime dependencies to PATH
|
# add runtime dependencies to PATH
|
||||||
wrapProgram $out/bin/${srcPath} \
|
wrapProgram $out/bin/${srcPath} \
|
||||||
--suffix PATH : ${lib.makeBinPath pkgsEnv }
|
--suffix PATH : ${lib.makeBinPath pkgsEnv }
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
} // (removeAttrs attrs [ "interpreter" "interpreterName" "pkgsEnv" "pkgExprs" "srcPath" ])
|
} // (removeAttrs attrs [ "interpreter" "interpreterName" "pkgsEnv" "pkgExprs" "srcPath" ])
|
||||||
);
|
);
|
||||||
|
|
Loading…
Reference in New Issue
Block a user