diff --git a/README.md b/README.md index 9e4d3f94..ec24ff97 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ firewall-cmd --reload also see [comparison to similar software](./docs/versus.md) * backend stuff - * ☑ IPv6 + * ☑ IPv6 + unix-sockets * ☑ [multiprocessing](#performance) (actual multithreading) * ☑ volumes (mountpoints) * ☑ [accounts](#accounts-and-volumes) @@ -1459,6 +1459,8 @@ some reverse proxies (such as [Caddy](https://caddyserver.com/)) can automatical * **warning:** nginx-QUIC (HTTP/3) is still experimental and can make uploads much slower, so HTTP/1.1 is recommended for now * depending on server/client, HTTP/1.1 can also be 5x faster than HTTP/2 +for improved security (and a tiny performance boost) consider listening on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1` + example webserver configs: * [nginx config](contrib/nginx/copyparty.conf) -- entire domain/subdomain @@ -1898,6 +1900,7 @@ some notes on hardening * cors doesn't work right otherwise * if you allow anonymous uploads or otherwise don't trust the contents of a volume, you can prevent XSS with volflag `nohtml` * this returns html documents as plaintext, and also disables markdown rendering +* when running behind a reverse-proxy, listen on a unix-socket with `-i /tmp/party.sock` instead of `-i 127.0.0.1` for tighter access control (plus you get a tiny performance boost for free) safety profiles: diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 63c68078..cdbf87f6 100644 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -969,8 +969,8 @@ def add_upload(ap): def add_network(ap): ap2 = ap.add_argument_group('network options') - ap2.add_argument("-i", metavar="IP", type=u, default="::", help="ip to bind (comma-sep.), default: all IPv4 and IPv6") - ap2.add_argument("-p", metavar="PORT", type=u, default="3923", help="ports to bind (comma/range)") + ap2.add_argument("-i", metavar="IP", type=u, default="::", help="ip to bind (comma-sep.) and/or [\033[32munix:/tmp/a.sock\033[0m], default: all IPv4 and IPv6") + ap2.add_argument("-p", metavar="PORT", type=u, default="3923", help="ports to bind (comma/range); ignored for unix-sockets") ap2.add_argument("--ll", action="store_true", help="include link-local IPv4/IPv6 in mDNS replies, even if the NIC has routable IPs (breaks some mDNS clients)") ap2.add_argument("--rproxy", metavar="DEPTH", type=int, default=1, help="which ip to associate clients with; [\033[32m0\033[0m]=tcp, [\033[32m1\033[0m]=origin (first x-fwd, unsafe), [\033[32m2\033[0m]=outermost-proxy, [\033[32m3\033[0m]=second-proxy, [\033[32m-1\033[0m]=closest-proxy") ap2.add_argument("--xff-hdr", metavar="NAME", type=u, default="x-forwarded-for", help="if reverse-proxied, which http header to read the client's real ip from") @@ -1394,6 +1394,7 @@ def add_debug(ap): ap2.add_argument("--no-scandir", action="store_true", help="kernel-bug workaround: disable scandir; do a listdir + stat on each file instead") ap2.add_argument("--no-fastboot", action="store_true", help="wait for initial filesystem indexing before accepting client requests") ap2.add_argument("--no-htp", action="store_true", help="disable httpserver threadpool, create threads as-needed instead") + ap2.add_argument("--rm-sck", action="store_true", help="when listening on unix-sockets, do a basic delete+bind instead of the default atomic bind") ap2.add_argument("--srch-dbg", action="store_true", help="explain search processing, and do some extra expensive sanity checks") ap2.add_argument("--rclone-mdns", action="store_true", help="use mdns-domain instead of server-ip on /?hc") ap2.add_argument("--stackmon", metavar="P,S", type=u, default="", help="write stacktrace to \033[33mP\033[0math every \033[33mS\033[0m second, for example --stackmon=\033[32m./st/%%Y-%%m/%%d/%%H%%M.xz,60") diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 3d54a8bd..e17e45c3 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -586,9 +586,15 @@ class Ftpd(object): if "::" in ips: ips.append("0.0.0.0") + ips = [x for x in ips if "unix:" not in x] + if self.args.ftp4: ips = [x for x in ips if ":" not in x] + if not ips: + lgr.fatal("cannot start ftp-server; no compatible IPs in -i") + return + ips = list(ODict.fromkeys(ips)) # dedup ioloop = IOLoop() diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 19655686..ff21ef53 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -13,6 +13,7 @@ import json import os import random import re +import socket import stat import string import threading # typechk @@ -314,8 +315,11 @@ class HttpCli(object): ) self.host = self.headers.get("host") or "" if not self.host: - zs = "%s:%s" % self.s.getsockname()[:2] - self.host = zs[7:] if zs.startswith("::ffff:") else zs + if self.s.family == socket.AF_UNIX: + self.host = self.args.name + else: + zs = "%s:%s" % self.s.getsockname()[:2] + self.host = zs[7:] if zs.startswith("::ffff:") else zs trusted_xff = False n = self.args.rproxy diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 6ee56f87..1b9cd869 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -243,15 +243,24 @@ class HttpSrv(object): return def listen(self, sck: socket.socket, nlisteners: int) -> None: + tcp = sck.family != socket.AF_UNIX + if self.args.j != 1: # lost in the pickle; redefine if not ANYWIN or self.args.reuseaddr: sck.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sck.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if tcp: + sck.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sck.settimeout(None) # < does not inherit, ^ opts above do - ip, port = sck.getsockname()[:2] + if tcp: + ip, port = sck.getsockname()[:2] + else: + ip = re.sub(r"\.[0-9]+$", "", sck.getsockname().split("/")[-1]) + port = 0 + self.srvs.append(sck) self.bound.add((ip, port)) self.nclimax = math.ceil(self.args.nc * 1.0 / nlisteners) @@ -263,10 +272,19 @@ class HttpSrv(object): def thr_listen(self, srv_sck: socket.socket) -> None: """listens on a shared tcp server""" - ip, port = srv_sck.getsockname()[:2] fno = srv_sck.fileno() - hip = "[{}]".format(ip) if ":" in ip else ip - msg = "subscribed @ {}:{} f{} p{}".format(hip, port, fno, os.getpid()) + if srv_sck.family == socket.AF_UNIX: + ip = re.sub(r"\.[0-9]+$", "", srv_sck.getsockname()) + msg = "subscribed @ %s f%d p%d" % (ip, fno, os.getpid()) + ip = ip.split("/")[-1] + port = 0 + tcp = False + else: + tcp = True + ip, port = srv_sck.getsockname()[:2] + hip = "[%s]" % (ip,) if ":" in ip else ip + msg = "subscribed @ %s:%d f%d p%d" % (hip, port, fno, os.getpid()) + self.log(self.name, msg) Daemon(self.broker.say, "sig-hsrv-up1", ("cb_httpsrv_up",)) @@ -338,11 +356,13 @@ class HttpSrv(object): try: sck, saddr = srv_sck.accept() - cip = unicode(saddr[0]) - if cip.startswith("::ffff:"): - cip = cip[7:] - - addr = (cip, saddr[1]) + if tcp: + cip = unicode(saddr[0]) + if cip.startswith("::ffff:"): + cip = cip[7:] + addr = (cip, saddr[1]) + else: + addr = (ip, sck.fileno()) except (OSError, socket.error) as ex: if self.stopping: break diff --git a/copyparty/tcpsrv.py b/copyparty/tcpsrv.py index 4ee1b4dd..ba5686c9 100644 --- a/copyparty/tcpsrv.py +++ b/copyparty/tcpsrv.py @@ -17,14 +17,16 @@ from .util import ( E_UNREACH, HAVE_IPV6, IP6ALL, + VF_CAREFUL, Netdev, + atomic_move, min_ex, sunpack, termsize, ) if True: - from typing import Generator + from typing import Generator, Union if TYPE_CHECKING: from .svchub import SvcHub @@ -217,14 +219,29 @@ class TcpSrv(object): if self.args.qr or self.args.qrs: self.qr = self._qr(qr1, qr2) + def nlog(self, msg: str, c: Union[int, str] = 0) -> None: + self.log("tcpsrv", msg, c) + def _listen(self, ip: str, port: int) -> None: - ipv = socket.AF_INET6 if ":" in ip else socket.AF_INET + if "unix:" in ip: + tcp = False + ipv = socket.AF_UNIX + ip = ip.split("unix:")[1] + elif ":" in ip: + tcp = True + ipv = socket.AF_INET6 + else: + tcp = True + ipv = socket.AF_INET + srv = socket.socket(ipv, socket.SOCK_STREAM) if not ANYWIN or self.args.reuseaddr: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - srv.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if tcp: + srv.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + srv.settimeout(None) # < does not inherit, ^ opts above do try: @@ -236,8 +253,19 @@ class TcpSrv(object): srv.setsockopt(socket.SOL_IP, socket.IP_FREEBIND, 1) try: - srv.bind((ip, port)) - sport = srv.getsockname()[1] + if tcp: + srv.bind((ip, port)) + else: + if ANYWIN or self.args.rm_sck: + if os.path.exists(ip): + os.unlink(ip) + srv.bind(ip) + else: + tf = "%s.%d" % (ip, os.getpid()) + srv.bind(tf) + atomic_move(self.nlog, tf, ip, VF_CAREFUL) + + sport = srv.getsockname()[1] if tcp else port if port != sport: # linux 6.0.16 lets you bind a port which is in use # except it just gives you a random port instead @@ -249,12 +277,23 @@ class TcpSrv(object): except: pass + e = "" if ex.errno in E_ADDR_IN_USE: e = "\033[1;31mport {} is busy on interface {}\033[0m".format(port, ip) + if not tcp: + e = "\033[1;31munix-socket {} is busy\033[0m".format(ip) elif ex.errno in E_ADDR_NOT_AVAIL: e = "\033[1;31minterface {} does not exist\033[0m".format(ip) - else: + + if not e: + if not tcp: + t = "\n\n\n NOTE: this crash may be due to a unix-socket bug; try --rm-sck\n" + self.log("tcpsrv", t, 2) raise + + if not tcp and not self.args.rm_sck: + e += "; maybe this is a bug? try --rm-sck" + raise Exception(e) def run(self) -> None: @@ -262,7 +301,14 @@ class TcpSrv(object): bound: list[tuple[str, int]] = [] srvs: list[socket.socket] = [] for srv in self.srv: - ip, port = srv.getsockname()[:2] + if srv.family == socket.AF_UNIX: + tcp = False + ip = re.sub(r"\.[0-9]+$", "", srv.getsockname()) + port = 0 + else: + tcp = True + ip, port = srv.getsockname()[:2] + if ip == IP6ALL: ip = "::" # jython @@ -294,8 +340,12 @@ class TcpSrv(object): bound.append((ip, port)) srvs.append(srv) fno = srv.fileno() - hip = "[{}]".format(ip) if ":" in ip else ip - msg = "listening @ {}:{} f{} p{}".format(hip, port, fno, os.getpid()) + if tcp: + hip = "[{}]".format(ip) if ":" in ip else ip + msg = "listening @ {}:{} f{} p{}".format(hip, port, fno, os.getpid()) + else: + msg = "listening @ {} f{} p{}".format(ip, fno, os.getpid()) + self.log("tcpsrv", msg) if self.args.q: print(msg) @@ -348,6 +398,8 @@ class TcpSrv(object): def detect_interfaces(self, listen_ips: list[str]) -> dict[str, Netdev]: from .stolen.ifaddr import get_adapters + listen_ips = [x for x in listen_ips if "unix:" not in x] + nics = get_adapters(True) eps: dict[str, Netdev] = {} for nic in nics: diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py index 91ebb3ae..c5c454af 100644 --- a/copyparty/tftpd.py +++ b/copyparty/tftpd.py @@ -166,9 +166,16 @@ class Tftpd(object): if "::" in ips: ips.append("0.0.0.0") + ips = [x for x in ips if "unix:" not in x] + if self.args.tftp4: ips = [x for x in ips if ":" not in x] + if not ips: + t = "cannot start tftp-server; no compatible IPs in -i" + self.nlog(t, 1) + return + ips = list(ODict.fromkeys(ips)) # dedup for ip in ips: diff --git a/docs/versus.md b/docs/versus.md index 6a71cf46..68b6a9b6 100644 --- a/docs/versus.md +++ b/docs/versus.md @@ -221,7 +221,7 @@ symbol legend, | serve sftp (ssh) | | | | | | █ | | | | | | █ | █ | | serve smb/cifs | ╱ | | | | | █ | | | | | | | | | serve dlna | | | | | | █ | | | | | | | | -| listen on unix-socket | | | | █ | █ | | █ | █ | █ | █ | █ | █ | | +| listen on unix-socket | █ | | | █ | █ | | █ | █ | █ | █ | █ | █ | | | zeroconf | █ | | | | | | | | | | | | █ | | supports netscape 4 | ╱ | | | | | █ | | | | | • | | ╱ | | ...internet explorer 6 | ╱ | █ | | █ | | █ | | | | | • | | ╱ | diff --git a/tests/util.py b/tests/util.py index e7768ad5..2181dda4 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,6 +6,7 @@ import os import platform import re import shutil +import socket import subprocess as sp import sys import tempfile @@ -124,7 +125,7 @@ class Cfg(Namespace): ex = "dotpart dotsrch hook_v no_dhash no_fastboot no_rescan no_sendfile no_snap no_voldump re_dhash plain_ip" ka.update(**{k: True for k in ex.split()}) - ex = "ah_cli ah_gen css_browser hist js_browser mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua" + ex = "ah_cli ah_gen css_browser hist js_browser js_other mime mimes no_forget no_hash no_idx nonsus_urls og_tpl og_ua" ka.update(**{k: None for k in ex.split()}) ex = "hash_mt srch_time u2abort u2j u2sz" @@ -200,6 +201,7 @@ class VSock(object): def __init__(self, buf): self._query = buf self._reply = b"" + self.family = socket.AF_INET self.sendall = self.send def recv(self, sz):