From 0504b010a13b14e515a698c1ed94af3a00dcbf41 Mon Sep 17 00:00:00 2001 From: ed Date: Sat, 17 Feb 2024 21:31:58 +0000 Subject: [PATCH] tftp: support ipv6 and utf-8 filenames + ... * fix winexe * missing newline after dirlist * optimizations --- README.md | 17 ++++-- copyparty/__main__.py | 1 + copyparty/tftpd.py | 100 +++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- scripts/make-sfx.sh | 4 +- scripts/pyinstaller/build.sh | 2 +- scripts/test/tftp.sh | 36 +++++++++++++ setup.py | 2 +- 8 files changed, 145 insertions(+), 19 deletions(-) create mode 100755 scripts/test/tftp.sh diff --git a/README.md b/README.md index 7ef075bd..6f7951a1 100644 --- a/README.md +++ b/README.md @@ -954,17 +954,24 @@ a TFTP server (read/write) can be started using `--tftp 3969` (you probably wan * based on [partftpy](https://github.com/9001/partftpy) * no accounts; read from world-readable folders, write to world-writable, overwrite in world-deletable * needs a dedicated port (cannot share with the HTTP/HTTPS API) - * run as root to use the spec-recommended port `69` (nice) + * run as root (or see below) to use the spec-recommended port `69` (nice) * can reply from a predefined portrange (good for firewalls) * only supports the binary/octet/image transfer mode (no netascii) * [RFC 7440](https://datatracker.ietf.org/doc/html/rfc7440) is **not** supported, so will be extremely slow over WAN - * expect 1100 KiB/s over 1000BASE-T, 400-500 KiB/s over wifi, 200 on bad wifi + * assuming default blksize (512), expect 1100 KiB/s over 100BASE-T, 400-500 KiB/s over wifi, 200 on bad wifi + +most clients expect to find TFTP on port 69, but on linux and macos you need to be root to listen on that. Alternatively, listen on 3969 and use NAT on the server to forward 69 to that port; +* on linux: `iptables -t nat -A PREROUTING -i eth0 -p udp --dport 69 -j REDIRECT --to-port 3969` some recommended TFTP clients: +* curl (cross-platform, read/write) + * get: `curl --tftp-blksize 1428 tftp://127.0.0.1:3969/firmware.bin` + * put: `curl --tftp-blksize 1428 -T firmware.bin tftp://127.0.0.1:3969/` * windows: `tftp.exe` (you probably already have it) + * `tftp -i 127.0.0.1 put firmware.bin` * linux: `tftp-hpa`, `atftp` - * `tftp 127.0.0.1 3969 -v -m binary -c put firmware.bin` -* `curl tftp://127.0.0.1:3969/firmware.bin` (read-only) + * `atftp --option "blksize 1428" 127.0.0.1 3969 -p -l firmware.bin -r firmware.bin` + * `tftp -v -m binary 127.0.0.1 3969 -c put firmware.bin` ## smb server @@ -997,7 +1004,7 @@ known client bugs: * however smb1 is buggy and is not enabled by default on win10 onwards * windows cannot access folders which contain filenames with invalid unicode or forbidden characters (`<>:"/\|?*`), or names ending with `.` -the smb protocol listens on TCP port 445, which is a privileged port on linux and macos, which would require running copyparty as root. However, this can be avoided by listening on another port using `--smb-port 3945` and then using NAT to forward the traffic from 445 to there; +the smb protocol listens on TCP port 445, which is a privileged port on linux and macos, which would require running copyparty as root. However, this can be avoided by listening on another port using `--smb-port 3945` and then using NAT on the server to forward the traffic from 445 to there; * on linux: `iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 445 -j REDIRECT --to-port 3945` authenticate with one of the following: diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 561f33c9..f4faa5ce 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -1019,6 +1019,7 @@ def add_tftp(ap): ap2.add_argument("--tftp", metavar="PORT", type=int, help="enable TFTP server on \033[33mPORT\033[0m, for example \033[32m69 \033[0mor \033[32m3969") ap2.add_argument("--tftpv", action="store_true", help="verbose") ap2.add_argument("--tftpvv", action="store_true", help="verboser") + ap2.add_argument("--tftp-no-fast", action="store_true", help="debug: disable optimizations") ap2.add_argument("--tftp-lsf", metavar="PTN", type=u, default="\\.?(dir|ls)(\\.txt)?", help="return a directory listing if a file with this name is requested and it does not exist; defaults matches .ls, dir, .dir.txt, ls.txt, ...") ap2.add_argument("--tftp-nols", action="store_true", help="if someone tries to download a directory, return an error instead of showing its directory listing") ap2.add_argument("--tftp-ipa", metavar="PFX", type=u, default="", help="only accept connections from IP-addresses starting with \033[33mPFX\033[0m; specify [\033[32many\033[0m] to disable inheriting \033[33m--ipa\033[0m. Example: [\033[32m127., 10.89., 192.168.\033[0m]") diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py index 62d29144..ac1aaedf 100644 --- a/copyparty/tftpd.py +++ b/copyparty/tftpd.py @@ -10,19 +10,33 @@ except: self.__dict__.update(attr) -import inspect import logging import os +import re +import socket import stat +import threading +import time from datetime import datetime -from partftpy import TftpContexts, TftpServer, TftpStates +try: + import inspect +except: + pass + +from partftpy import ( + TftpContexts, + TftpPacketFactory, + TftpPacketTypes, + TftpServer, + TftpStates, +) from partftpy.TftpShared import TftpException -from .__init__ import PY2, TYPE_CHECKING +from .__init__ import EXE, TYPE_CHECKING from .authsrv import VFS from .bos import bos -from .util import BytesIO, Daemon, exclude_dotfiles, runhook, undot +from .util import BytesIO, Daemon, exclude_dotfiles, min_ex, runhook, undot if True: # pylint: disable=using-constant-test from typing import Any, Union @@ -35,6 +49,10 @@ lg = logging.getLogger("tftp") debug, info, warning, error = (lg.debug, lg.info, lg.warning, lg.error) +def noop(*a, **ka) -> None: + pass + + def _serverInitial(self, pkt: Any, raddress: str, rport: int) -> bool: info("connection from %s:%s", raddress, rport) ret = _orig_serverInitial(self, pkt, raddress, rport) @@ -56,6 +74,7 @@ class Tftpd(object): self.args = hub.args self.asrv = hub.asrv self.log = hub.log + self.mutex = threading.Lock() _hub[:] = [] _hub.append(hub) @@ -65,6 +84,38 @@ class Tftpd(object): lgr = logging.getLogger(x) lgr.setLevel(logging.DEBUG if self.args.tftpv else logging.INFO) + if not self.args.tftpv and not self.args.tftpvv: + # contexts -> states -> packettypes -> shared + # contexts -> packetfactory + # packetfactory -> packettypes + Cs = [ + TftpPacketTypes, + TftpPacketFactory, + TftpStates, + TftpContexts, + TftpServer, + ] + cbak = [] + if not self.args.tftp_no_fast and not EXE: + try: + import inspect + + ptn = re.compile(r"(^\s*)log\.debug\(.*\)$") + for C in Cs: + cbak.append(C.__dict__) + src1 = inspect.getsource(C).split("\n") + src2 = "\n".join([ptn.sub("\\1pass", ln) for ln in src1]) + cfn = C.__spec__.origin + exec (compile(src2, filename=cfn, mode="exec"), C.__dict__) + except Exception: + t = "failed to optimize tftp code; run with --tftp-noopt if there are issues:\n" + self.log("tftp", t + min_ex(), 3) + for n, zd in enumerate(cbak): + Cs[n].__dict__ = zd + + for C in Cs: + C.log.debug = noop + # patch vfs into partftpy TftpContexts.open = self._open TftpStates.open = self._open @@ -102,21 +153,52 @@ class Tftpd(object): self.log("tftp", "IPv6 not supported for tftp; listening on 0.0.0.0", 3) ip = "0.0.0.0" - self.ip = ip self.port = int(self.args.tftp) - self.srv = TftpServer.TftpServer("/", self._ls) - self.stop = self.srv.stop + self.srv = [] + self.ips = [] ports = [] if self.args.tftp_pr: p1, p2 = [int(x) for x in self.args.tftp_pr.split("-")] ports = list(range(p1, p2 + 1)) - Daemon(self.srv.listen, "tftp", [self.ip, self.port], ka={"ports": ports}) + ips = self.args.i + if "::" in ips: + ips.append("0.0.0.0") + + if self.args.ftp4: + ips = [x for x in ips if ":" not in x] + + for ip in ips: + name = "tftp_%s" % (ip,) + Daemon(self._start, name, [ip, ports]) + time.sleep(0.2) # give dualstack a chance def nlog(self, msg: str, c: Union[int, str] = 0) -> None: self.log("tftp", msg, c) + def _start(self, ip, ports): + fam = socket.AF_INET6 if ":" in ip else socket.AF_INET + srv = TftpServer.TftpServer("/", self._ls) + with self.mutex: + self.srv.append(srv) + self.ips.append(ip) + try: + srv.listen(ip, self.port, af_family=fam, ports=ports) + except OSError: + with self.mutex: + self.srv.remove(srv) + self.ips.remove(ip) + if ip != "0.0.0.0" or "::" not in self.ips: + raise + + def stop(self): + with self.mutex: + srvs = self.srv[:] + + for srv in srvs: + srv.stop() + def _v2a(self, caller: str, vpath: str, perms: list, *a: Any) -> tuple[VFS, str]: vpath = vpath.replace("\\", "/").lstrip("/") if not perms: @@ -190,7 +272,7 @@ class Tftpd(object): retl = ["# permissions: %s" % (", ".join(perms),)] retl += [fmt.format(*x) for x in ls] ret = "\n".join(retl).encode("utf-8", "replace") - return BytesIO(ret) + return BytesIO(ret + b"\n") def _open(self, vpath: str, mode: str, *a: Any, **ka: Any) -> Any: rd = wr = False diff --git a/pyproject.toml b/pyproject.toml index 77e25380..5b23c62a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ thumbnails2 = ["pyvips"] audiotags = ["mutagen"] ftpd = ["pyftpdlib"] ftps = ["pyftpdlib", "pyopenssl"] -tftpd = ["partftpy>=0.2.0"] +tftpd = ["partftpy>=0.3.0"] pwhash = ["argon2-cffi"] [project.scripts] diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh index 44f26938..0c77f5f2 100755 --- a/scripts/make-sfx.sh +++ b/scripts/make-sfx.sh @@ -225,9 +225,9 @@ necho() { mv pyftpdlib ftp/ necho collecting partftpy - f="../build/partftpy-0.2.0.tar.gz" + f="../build/partftpy-0.3.0.tar.gz" [ -e "$f" ] || - (url=https://files.pythonhosted.org/packages/64/4a/360dde1e7277758a4ccb0d6434ec661042d9d745aa6c3baa9ec0699df3e9/partftpy-0.2.0.tar.gz; + (url=https://files.pythonhosted.org/packages/06/ce/531978c831c47f79bc72d5bbb3f12757daf1602d1fffad012305f2d270f6/partftpy-0.3.0.tar.gz; wget -O$f "$url" || curl -L "$url" >$f) tar -zxf $f diff --git a/scripts/pyinstaller/build.sh b/scripts/pyinstaller/build.sh index 5bf0c5c3..adf94c98 100644 --- a/scripts/pyinstaller/build.sh +++ b/scripts/pyinstaller/build.sh @@ -37,7 +37,7 @@ rm -rf $TEMP/pe-copyparty* python copyparty-sfx.py --version rm -rf mods; mkdir mods -cp -pR $TEMP/pe-copyparty/copyparty/ $TEMP/pe-copyparty/{ftp,j2}/* mods/ +cp -pR $TEMP/pe-copyparty/{copyparty,partftpy}/ $TEMP/pe-copyparty/{ftp,j2}/* mods/ [ $w10 ] && rm -rf mods/{jinja2,markupsafe} af() { awk "$1" <$2 >tf; mv tf "$2"; } diff --git a/scripts/test/tftp.sh b/scripts/test/tftp.sh new file mode 100755 index 00000000..b52accb1 --- /dev/null +++ b/scripts/test/tftp.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -ex + +# PYTHONPATH=.:~/dev/partftpy/ taskset -c 0 python3 -m copyparty -v srv::r -v srv/junk:junk:A --tftp 3969 + +get_src=~/dev/copyparty/srv/palette.flac +get_fn=${get_src##*/} + +put_src=~/Downloads/102.zip +put_dst=~/dev/copyparty/srv/junk/102.zip + +cd /dev/shm + +echo curl get 1428 v4; curl --tftp-blksize 1428 tftp://127.0.0.1:3969/$get_fn | cmp $get_src || exit 1 +echo curl get 1428 v6; curl --tftp-blksize 1428 tftp://[::1]:3969/$get_fn | cmp $get_src || exit 1 + +echo curl put 1428 v4; rm -f $put_dst && curl --tftp-blksize 1428 -T $put_src tftp://127.0.0.1:3969/junk/ && cmp $put_src $put_dst || exit 1 +echo curl put 1428 v6; rm -f $put_dst && curl --tftp-blksize 1428 -T $put_src tftp://[::1]:3969/junk/ && cmp $put_src $put_dst || exit 1 + +echo atftp get 1428; rm -f $get_fn && ~/src/atftp/atftp --option "blksize 1428" -g -r $get_fn 127.0.0.1 3969 && cmp $get_fn $get_src || exit 1 + +echo atftp put 1428; rm -f $put_dst && ~/src/atftp/atftp --option "blksize 1428" 127.0.0.1 3969 -p -l $put_src -r junk/102.zip && cmp $put_src $put_dst || exit 1 + +echo tftp-hpa get; rm -f $put_dst && tftp -v -m binary 127.0.0.1 3969 -c get $get_fn && cmp $get_src $get_fn || exit 1 + +echo tftp-hpa put; rm -f $put_dst && tftp -v -m binary 127.0.0.1 3969 -c put $put_src junk/102.zip && cmp $put_src $put_dst || exit 1 + +echo curl get 512; curl tftp://127.0.0.1:3969/$get_fn | cmp $get_src || exit 1 + +echo curl put 512; rm -f $put_dst && curl -T $put_src tftp://127.0.0.1:3969/junk/ && cmp $put_src $put_dst || exit 1 + +echo atftp get 512; rm -f $get_fn && ~/src/atftp/atftp -g -r $get_fn 127.0.0.1 3969 && cmp $get_fn $get_src || exit 1 + +echo atftp put 512; rm -f $put_dst && ~/src/atftp/atftp 127.0.0.1 3969 -p -l $put_src -r junk/102.zip && cmp $put_src $put_dst || exit 1 + +echo nice diff --git a/setup.py b/setup.py index 4e52add3..b6bf7576 100755 --- a/setup.py +++ b/setup.py @@ -141,7 +141,7 @@ args = { "audiotags": ["mutagen"], "ftpd": ["pyftpdlib"], "ftps": ["pyftpdlib", "pyopenssl"], - "tftpd": ["partftpy>=0.2.0"], + "tftpd": ["partftpy>=0.3.0"], "pwhash": ["argon2-cffi"], }, "entry_points": {"console_scripts": ["copyparty = copyparty.__main__:main"]},