From 5cd9d11329f2b6a0dcc79771ad7444d209d03b01 Mon Sep 17 00:00:00 2001 From: ed Date: Tue, 22 Nov 2022 21:40:12 +0000 Subject: [PATCH] add ssdp responder --- copyparty/__main__.py | 15 +++- copyparty/httpcli.py | 7 +- copyparty/httpsrv.py | 13 ++- copyparty/mdns.py | 11 ++- copyparty/multicast.py | 17 +++- copyparty/ssdp.py | 181 +++++++++++++++++++++++++++++++++++++++++ copyparty/svchub.py | 23 +++++- copyparty/util.py | 5 ++ 8 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 copyparty/ssdp.py diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 04c9c1f2..398afabc 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -20,6 +20,7 @@ import sys import threading import time import traceback +import uuid from textwrap import dedent from .__init__ import ANYWIN, CORES, PY2, VT100, WINDOWS, E, EnvParams, unicode @@ -663,6 +664,11 @@ def run_argparse( ap2.add_argument("--ssl-log", metavar="PATH", type=u, help="log master secrets for later decryption in wireshark") ap2 = ap.add_argument_group("Zeroconf options") + ap2.add_argument("-z", action="store_true", help="enable all zeroconf backends (mdns, ssdp)") + ap2.add_argument("-zv", action="store_true", help="verbose all zeroconf backends") + ap2.add_argument("--mc-hop", metavar="SEC", type=int, default=0, help="rejoin multicast groups every SEC seconds (workaround for some switches/routers which cause mDNS to suddenly stop working after some time); try [\033[32m300\033[0m] or [\033[32m180\033[0m]") + + ap2 = ap.add_argument_group("Zeroconf-mDNS options:") ap2.add_argument("--zm", action="store_true", help="announce the enabled protocols over mDNS (multicast DNS-SD) -- compatible with KDE, gnome, macOS, ...") ap2.add_argument("--zm4", action="store_true", help="IPv4 only -- try this if some clients can't connect") ap2.add_argument("--zm6", action="store_true", help="IPv6 only") @@ -676,7 +682,14 @@ def run_argparse( ap2.add_argument("--zm-mnic", action="store_true", help="merge NICs which share subnets; assume that same subnet means same network") ap2.add_argument("--zm-msub", action="store_true", help="merge subnets on each NIC -- always enabled for ipv6 -- reduces network load, but gnome-gvfs clients may stop working") ap2.add_argument("--zm-noneg", action="store_true", help="disable NSEC replies -- try this if some clients don't see copyparty") - ap2.add_argument("--mc-hop", metavar="SEC", type=int, default=0, help="rejoin multicast groups every SEC seconds (workaround for some switches/routers which cause mDNS to suddenly stop working after some time); try [\033[32m300\033[0m] or [\033[32m180\033[0m]") + + ap2 = ap.add_argument_group("Zeroconf-SSDP options:") + ap2.add_argument("--zs", action="store_true", help="announce the enabled protocols over SSDP -- compatible with Windows") + # ap2.add_argument("--zs4", action="store_true", help="IPv4 only") + # ap2.add_argument("--zs6", action="store_true", help="IPv6 only") + ap2.add_argument("--zsv", action="store_true", help="verbose SSDP") + ap2.add_argument("--zsl", metavar="PATH", type=u, default="", help="location to include in the url (or a complete external URL), for example [\033[32mpriv/?pw=hunter2\033[0m] or [\033[32mpriv/?pw=hunter2\033[0m]") + ap2.add_argument("--zsid", metavar="UUID", type=u, default=uuid.uuid4().urn[4:], help="USN (device identifier) to announce") ap2 = ap.add_argument_group('FTP options') ap2.add_argument("--ftp", metavar="PORT", type=int, help="enable FTP server on PORT, for example \033[32m3921") diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 88e57bed..26bc9809 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -643,6 +643,9 @@ class HttpCli(object): if self.vpath.startswith(".cpr/ico/"): return self.tx_ico(self.vpath.split("/")[-1], exact=True) + if self.vpath.startswith(".cpr/ssdp"): + return self.conn.hsrv.ssdp.reply(self) + static_path = os.path.join(self.E.mod, "web/", self.vpath[5:]) return self.tx_file(static_path) @@ -2626,14 +2629,14 @@ class HttpCli(object): return True def set_k304(self) -> bool: - ck = gencookie("k304", self.uparam["k304"], 60 * 60 * 24 * 365) + ck = gencookie("k304", self.uparam["k304"], 60 * 60 * 24 * 299) self.out_headerlist.append(("Set-Cookie", ck)) self.redirect("", "?h#cc") return True def set_am_js(self) -> bool: v = "n" if self.uparam["am_js"] == "n" else "y" - ck = gencookie("js", v, 60 * 60 * 24 * 365) + ck = gencookie("js", v, 60 * 60 * 24 * 299) self.out_headerlist.append(("Set-Cookie", ck)) self.reply(b"promoted\n") return True diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index f6558754..fb3eb0c8 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -47,6 +47,7 @@ from .util import ( if TYPE_CHECKING: from .broker_util import BrokerCli + from .ssdp import SSDPr if True: # pylint: disable=using-constant-test from typing import Any, Optional @@ -71,11 +72,14 @@ class HttpSrv(object): nsuf = "-n{}-i{:x}".format(nid, os.getpid()) if nid else "" self.magician = Magician() + self.ssdp: Optional["SSDPr"] = None self.gpwd = Garda(self.args.ban_pw) self.g404 = Garda(self.args.ban_404) self.bans: dict[str, int] = {} self.aclose: dict[str, int] = {} + self.ip = "" + self.port = 0 self.name = "hsrv" + nsuf self.mutex = threading.Lock() self.stopping = False @@ -105,6 +109,11 @@ class HttpSrv(object): zs = os.path.join(self.E.mod, "web", "deps", "prism.js.gz") self.prism = os.path.exists(zs) + if self.args.zs: + from .ssdp import SSDPr + + self.ssdp = SSDPr(broker) + cert_path = os.path.join(self.E.cfg, "cert.pem") if bos.path.exists(cert_path): self.cert_path = cert_path @@ -173,12 +182,12 @@ class HttpSrv(object): sck.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sck.settimeout(None) # < does not inherit, ^ does - ip, port = sck.getsockname()[:2] + self.ip, self.port = sck.getsockname()[:2] self.srvs.append(sck) self.nclimax = math.ceil(self.args.nc * 1.0 / nlisteners) Daemon( self.thr_listen, - "httpsrv-n{}-listen-{}-{}".format(self.nid or "0", ip, port), + "httpsrv-n{}-listen-{}-{}".format(self.nid or "0", self.ip, self.port), (sck,), ) diff --git a/copyparty/mdns.py b/copyparty/mdns.py index 9f4ee850..64dd97e6 100644 --- a/copyparty/mdns.py +++ b/copyparty/mdns.py @@ -62,11 +62,10 @@ class MDNS(MCast): def __init__(self, hub: "SvcHub") -> None: grp4 = "" if hub.args.zm6 else MDNS4 grp6 = "" if hub.args.zm4 else MDNS6 - super(MDNS, self).__init__(hub, MDNS_Sck, grp4, grp6, 5353) + super(MDNS, self).__init__(hub, MDNS_Sck, grp4, grp6, 5353, hub.args.zmv) self.srv: dict[socket.socket, MDNS_Sck] = {} self.ttl = 300 - self.running = True zs = self.args.name + ".local." zs = zs.encode("ascii", "replace").decode("ascii", "replace") @@ -310,12 +309,12 @@ class MDNS(MCast): self.srv = {} - def eat(self, buf: bytes, addr: tuple[str, int], sck: socket.socket): + def eat(self, buf: bytes, addr: tuple[str, int], sck: socket.socket) -> None: cip = addr[0] - if cip.startswith("169.254"): + v6 = ":" in cip + if cip.startswith("169.254") or v6 and not cip.startswith("fe80"): return - v6 = ":" in cip cache = self.rx6 if v6 else self.rx4 if buf in cache.c: return @@ -327,7 +326,7 @@ class MDNS(MCast): now = time.time() - if self.args.zmv: + if self.args.zmv and cip != srv.ip and cip not in srv.ips: t = "{} [{}] \033[36m{} \033[0m|{}|" self.log(t.format(srv.name, srv.ip, cip, len(buf)), "90") diff --git a/copyparty/multicast.py b/copyparty/multicast.py index 62428a19..2d121d65 100644 --- a/copyparty/multicast.py +++ b/copyparty/multicast.py @@ -7,7 +7,7 @@ import time import ipaddress from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network -from .__init__ import TYPE_CHECKING, MACOS +from .__init__ import MACOS, TYPE_CHECKING from .util import min_ex, spack if TYPE_CHECKING: @@ -47,7 +47,13 @@ class MC_Sck(object): class MCast(object): def __init__( - self, hub: "SvcHub", Srv: type[MC_Sck], mc_grp_4: str, mc_grp_6: str, port: int + self, + hub: "SvcHub", + Srv: type[MC_Sck], + mc_grp_4: str, + mc_grp_6: str, + port: int, + vinit: bool, ) -> None: """disable ipv%d by setting mc_grp_%d empty""" self.hub = hub @@ -58,6 +64,7 @@ class MCast(object): self.grp4 = mc_grp_4 self.grp6 = mc_grp_6 self.port = port + self.vinit = vinit self.srv: dict[socket.socket, MC_Sck] = {} # listening sockets self.sips: set[str] = set() # all listening ips (including failed attempts) @@ -66,6 +73,8 @@ class MCast(object): self.b6: list[bytes] = [] # sorted list of binary-ips self.cscache: dict[str, Optional[MC_Sck]] = {} # client ip -> server cache + self.running = True + def log(self, msg: str, c: Union[int, str] = 0) -> None: self.log_func("multicast", msg, c) @@ -188,7 +197,7 @@ class MCast(object): def setup_socket(self, srv: MC_Sck) -> None: sck = srv.sck if srv.v6: - if self.args.zmv: + if self.vinit: zsl = list(srv.ips.keys()) self.log("v6({}) idx({}) {}".format(srv.ip, srv.idx, zsl), 6) @@ -214,7 +223,7 @@ class MCast(object): t = "failed to set IPv6 TTL/LOOP; announcements may not survive multiple switches/routers" self.log(t, 3) else: - if self.args.zmv: + if self.vinit: self.log("v4({}) idx({})".format(srv.ip, srv.idx), 6) bip = socket.inet_aton(srv.ip) diff --git a/copyparty/ssdp.py b/copyparty/ssdp.py new file mode 100644 index 00000000..f9eb65fa --- /dev/null +++ b/copyparty/ssdp.py @@ -0,0 +1,181 @@ +# coding: utf-8 +from __future__ import print_function, unicode_literals + +import re +import select +import socket +from email.utils import formatdate + +from .__init__ import TYPE_CHECKING +from .multicast import MC_Sck, MCast +from .util import CachedSet, min_ex + +if TYPE_CHECKING: + from .broker_util import BrokerCli + from .httpcli import HttpCli + from .svchub import SvcHub + +if True: # pylint: disable=using-constant-test + from typing import Optional, Union + + +SSDP4 = "239.255.255.250" +SSDP6 = "ff02::c" + + +class SSDPr(object): + """generates http responses for httpcli""" + + def __init__(self, broker: "BrokerCli") -> None: + self.broker = broker + self.args = broker.args + + def reply(self, hc: "HttpCli") -> bool: + if hc.vpath.endswith("device.xml"): + return self.tx_device(hc) + + hc.reply(b"unknown request", 400) + return False + + def tx_device(self, hc: "HttpCli") -> bool: + zs = """ + + + + 1 + 0 + + {} + + {} + urn:schemas-upnp-org:device:Basic:1 + {} + file server + ed + https://ocv.me/ + copyparty + https://github.com/9001/copyparty/ + {} + + + urn:schemas-upnp-org:device:Basic:1 + urn:schemas-upnp-org:device:Basic + /.cpr/ssdp/services.xml + /.cpr/ssdp/services.xml + /.cpr/ssdp/services.xml + + + +""" + + sip, sport = hc.s.getsockname()[:2] + proto = "https" if self.args.https_only else "http" + ubase = "{}://{}:{}".format(proto, sip, sport) + zsl = self.args.zsl + url = zsl if "://" in zsl else ubase + "/" + zsl.lstrip("/") + zs = zs.strip().format(ubase, url, self.args.name, self.args.zsid) + hc.reply(zs.encode("utf-8", "replace")) + return False # close connectino + + +class SSDPd(MCast): + """communicates with ssdp clients over multicast""" + + def __init__(self, hub: "SvcHub") -> None: + # grp4 = "" if hub.args.zs6 else SSDP4 + # grp6 = "" if hub.args.zs4 else SSDP6 + # no way to find routable IPv6 between us and them + grp4 = SSDP4 + grp6 = "" + vinit = hub.args.zsv and not hub.args.zmv + super(SSDPd, self).__init__(hub, MC_Sck, grp4, grp6, 1900, vinit) + self.srv: dict[socket.socket, MC_Sck] = {} + self.rx4 = CachedSet(0.7) + self.rx6 = CachedSet(0.7) + self.txc = CachedSet(5) # win10: every 3 sec + self.ptn_st = re.compile(b"\nst: *upnp:rootdevice", re.I) + + def log(self, msg: str, c: Union[int, str] = 0) -> None: + self.log_func("SSDP", msg, c) + + def run(self) -> None: + bound = self.create_servers() + if not bound: + self.log("failed to announce copyparty services on the network", 3) + return + + self.log("listening") + while self.running: + rdy = select.select(self.srv, [], [], 180) + rx: list[socket.socket] = rdy[0] # type: ignore + self.rx4.cln() + self.rx6.cln() + for sck in rx: + buf, addr = sck.recvfrom(4096) + try: + self.eat(buf, addr, sck) + except: + if not self.running: + return + + t = "{} {} \033[33m|{}| {}\n{}".format( + self.srv[sck].name, addr, len(buf), repr(buf)[2:-1], min_ex() + ) + self.log(t, 6) + + def stop(self) -> None: + self.running = False + self.srv = {} + + def eat(self, buf: bytes, addr: tuple[str, int], sck: socket.socket) -> None: + cip = addr[0] + v6 = ":" in cip + if cip.startswith("169.254") or v6 and not cip.startswith("fe80"): + return + + cache = self.rx6 if v6 else self.rx4 + if buf in cache.c: + return + + cache.add(buf) + srv: Optional[MC_Sck] = self.srv[sck] if v6 else self.map_client(cip) # type: ignore + if not srv: + return + + if not buf.startswith(b"M-SEARCH * HTTP/1."): + raise Exception("not an ssdp message") + + if not self.ptn_st.search(buf): + return + + if self.args.zsv: + t = "{} [{}] \033[36m{} \033[0m|{}|" + self.log(t.format(srv.name, srv.ip, cip, len(buf)), "90") + + sip = "[{}]".format(srv.ip) if v6 else srv.ip + sport = self.args.p[0] # xxx + + zs = """ +HTTP/1.1 200 OK +CACHE-CONTROL: max-age=1800 +DATE: {0} +EXT: +LOCATION: http://{1}:{2}/.cpr/ssdp/device.xml +OPT: "http://schemas.upnp.org/upnp/1/0/"; ns=01 +01-NLS: {3} +SERVER: UPnP/1.0 +ST: upnp:rootdevice +USN: {3}::upnp:rootdevice +BOOTID.UPNP.ORG: 0 +CONFIGID.UPNP.ORG: 1 + +""" + zs = zs.format(formatdate(usegmt=True), sip, sport, self.args.zsid) + zb = zs[1:].replace("\n", "\r\n").encode("utf-8", "replace") + srv.sck.sendto(zb, addr[:2]) + + if cip not in self.txc.c: + self.log("{} [{}] --> {}".format(srv.name, srv.ip, cip), "6") + + self.txc.add(cip) + self.txc.cln() diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 35c87bf5..13defa0e 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -1,9 +1,6 @@ # coding: utf-8 from __future__ import print_function, unicode_literals -# from inspect import currentframe -# print(currentframe().f_lineno) - import argparse import base64 import calendar @@ -20,6 +17,10 @@ import threading import time from datetime import datetime, timedelta +# from inspect import currentframe +# print(currentframe().f_lineno) + + if True: # pylint: disable=using-constant-test from types import FrameType @@ -49,6 +50,7 @@ from .util import ( if TYPE_CHECKING: try: from .mdns import MDNS + from .ssdp import SSDPd except: pass @@ -235,6 +237,7 @@ class SvcHub(object): args.zms = zms self.mdns: Optional["MDNS"] = None + self.ssdp: Optional["SSDPd"] = None # decide which worker impl to use if self.check_mp_enable(): @@ -395,6 +398,15 @@ class SvcHub(object): except: self.log("root", "mdns startup failed;\n" + min_ex(), 3) + if getattr(self.args, "zs", False): + try: + from .ssdp import SSDPd + + self.ssdp = SSDPd(self) + Daemon(self.ssdp.run, "ssdp") + except: + self.log("root", "ssdp startup failed;\n" + min_ex(), 3) + Daemon(self.thr_httpsrv_up, "sig-hsrv-up2") sigs = [signal.SIGINT, signal.SIGTERM] @@ -501,10 +513,15 @@ class SvcHub(object): try: self.pr("OPYTHAT") slp = 0.0 + if self.mdns: Daemon(self.mdns.stop) slp = time.time() + 0.5 + if self.ssdp: + Daemon(self.ssdp.stop) + slp = time.time() + 0.5 + self.tcpsrv.shutdown() self.broker.shutdown() self.up2k.shutdown() diff --git a/copyparty/util.py b/copyparty/util.py index 246e7147..6ec54fe7 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -181,9 +181,14 @@ IMPLICATIONS = [ ["smbvvv", "smbvv"], ["smbvv", "smbv"], ["smbv", "smb"], + ["zv", "zmv"], + ["zv", "zsv"], + ["z", "zm"], + ["z", "zs"], ["zmvv", "zmv"], ["zmv", "zm"], ["zms", "zm"], + ["zsv", "zs"], ]