From d636316a19ecf6ba269b0b5d7e4358ccba7cdf8e Mon Sep 17 00:00:00 2001
From: ed
Date: Sat, 10 Feb 2024 18:37:21 +0000
Subject: [PATCH 01/36] add tftp server
---
README.md | 27 +++-
contrib/package/arch/PKGBUILD | 2 +-
copyparty/__main__.py | 15 ++-
copyparty/svchub.py | 28 +++-
copyparty/tcpsrv.py | 1 +
copyparty/tftpd.py | 241 ++++++++++++++++++++++++++++++++++
copyparty/util.py | 15 ++-
docs/devnotes.md | 1 +
docs/lics.txt | 4 +
docs/versus.md | 7 +-
pyproject.toml | 1 +
scripts/make-sfx.sh | 28 +++-
scripts/sfx.ls | 1 +
setup.py | 3 +-
14 files changed, 348 insertions(+), 26 deletions(-)
create mode 100644 copyparty/tftpd.py
diff --git a/README.md b/README.md
index 4689005b..37c1bb94 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
turn almost any device into a file server with resumable uploads/downloads using [*any*](#browser-support) web browser
* server only needs Python (2 or 3), all dependencies optional
-* ๐ protocols: [http](#the-browser) // [ftp](#ftp-server) // [webdav](#webdav-server) // [smb/cifs](#smb-server)
+* ๐ protocols: [http](#the-browser) // [webdav](#webdav-server) // [ftp](#ftp-server) // [tftp](#tftp-server) // [smb/cifs](#smb-server)
* ๐ฑ [android app](#android-app) // [iPhone shortcuts](#ios-shortcuts)
๐ **[Get started](#quickstart)!** or visit the **[read-only demo server](https://a.ocv.me/pub/demo/)** ๐ running from a basement in finland
@@ -53,6 +53,7 @@ turn almost any device into a file server with resumable uploads/downloads using
* [ftp server](#ftp-server) - an FTP server can be started using `--ftp 3921`
* [webdav server](#webdav-server) - with read-write support
* [connecting to webdav from windows](#connecting-to-webdav-from-windows) - using the GUI
+ * [tftp server](#tftp-server) - a TFTP server (read/write) can be started using `--tftp 3969`
* [smb server](#smb-server) - unsafe, slow, not recommended for wan
* [browser ux](#browser-ux) - tweaking the ui
* [file indexing](#file-indexing) - enables dedup and music search ++
@@ -157,11 +158,11 @@ you may also want these, especially on servers:
and remember to open the ports you want; here's a complete example including every feature copyparty has to offer:
```
firewall-cmd --permanent --add-port={80,443,3921,3923,3945,3990}/tcp # --zone=libvirt
-firewall-cmd --permanent --add-port=12000-12099/tcp --permanent # --zone=libvirt
-firewall-cmd --permanent --add-port={1900,5353}/udp # --zone=libvirt
+firewall-cmd --permanent --add-port=12000-12099/tcp # --zone=libvirt
+firewall-cmd --permanent --add-port={69,1900,3969,5353}/udp # --zone=libvirt
firewall-cmd --reload
```
-(1900:ssdp, 3921:ftp, 3923:http/https, 3945:smb, 3990:ftps, 5353:mdns, 12000:passive-ftp)
+(69:tftp, 1900:ssdp, 3921:ftp, 3923:http/https, 3945:smb, 3969:tftp, 3990:ftps, 5353:mdns, 12000:passive-ftp)
## features
@@ -172,6 +173,7 @@ firewall-cmd --reload
* โ volumes (mountpoints)
* โ [accounts](#accounts-and-volumes)
* โ [ftp server](#ftp-server)
+ * โ [tftp server](#tftp-server)
* โ [webdav server](#webdav-server)
* โ [smb/cifs server](#smb-server)
* โ [qr-code](#qr-code) for quick access
@@ -943,6 +945,23 @@ known client bugs:
* latin-1 is fine, hiragana is not (not even as shift-jis on japanese xp)
+## tftp server
+
+a TFTP server (read/write) can be started using `--tftp 3969` (you probably want [ftp](#ftp-server) instead unless you are *actually* communicating with hardware from the 80s (in which case we should definitely hang some time))
+
+* based on [partftpy](https://github.com/9001/partftpy)
+* needs a dedicated port (cannot share with the HTTP/HTTPS API)
+ * run as root to use the spec-recommended port `69` (nice)
+* no accounts; read from world-readable folders, write to world-writable, overwrite in world-deletable
+* [RFC 7440](https://datatracker.ietf.org/doc/html/rfc7440) is **not** supported (will be extremely slow over WAN)
+
+some recommended TFTP clients:
+* windows: `tftp.exe` (you probably already have it)
+* linux: `tftp-hpa`, `atftp`
+ * `tftp 127.0.0.1 3969 -v -m binary -c put initrd.bin`
+* `curl` (read-only)
+
+
## smb server
unsafe, slow, not recommended for wan, enable with `--smb` for read-only or `--smbw` for read-write
diff --git a/contrib/package/arch/PKGBUILD b/contrib/package/arch/PKGBUILD
index 98c8854a..34c86870 100644
--- a/contrib/package/arch/PKGBUILD
+++ b/contrib/package/arch/PKGBUILD
@@ -2,7 +2,7 @@
pkgname=copyparty
pkgver="1.9.31"
pkgrel=1
-pkgdesc="File server with accelerated resumable uploads, dedup, WebDAV, FTP, zeroconf, media indexer, thumbnails++"
+pkgdesc="File server with accelerated resumable uploads, dedup, WebDAV, FTP, TFTP, zeroconf, media indexer, thumbnails++"
arch=("any")
url="https://github.com/9001/${pkgname}"
license=('MIT')
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index f7161235..ef1540f9 100755
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -46,6 +46,7 @@ from .util import (
PY_DESC,
PYFTPD_VER,
SQLITE_VER,
+ PARTFTPY_VER,
UNPLICATIONS,
align_tab,
ansi_re,
@@ -993,7 +994,7 @@ def add_zc_ssdp(ap):
def add_ftp(ap):
- ap2 = ap.add_argument_group('FTP options')
+ ap2 = ap.add_argument_group('FTP options (TCP only)')
ap2.add_argument("--ftp", metavar="PORT", type=int, help="enable FTP server on \033[33mPORT\033[0m, for example \033[32m3921")
ap2.add_argument("--ftps", metavar="PORT", type=int, help="enable FTPS server on \033[33mPORT\033[0m, for example \033[32m3990")
ap2.add_argument("--ftpv", action="store_true", help="verbose")
@@ -1013,6 +1014,14 @@ def add_webdav(ap):
ap2.add_argument("--dav-auth", action="store_true", help="force auth for all folders (required by davfs2 when only some folders are world-readable) (volflag=davauth)")
+def add_tftp(ap):
+ ap2 = ap.add_argument_group('TFTP options (UDP only)')
+ 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-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]")
+
+
def add_smb(ap):
ap2 = ap.add_argument_group('SMB/CIFS options')
ap2.add_argument("--smb", action="store_true", help="enable smb (read-only) -- this requires running copyparty as root on linux and macos unless \033[33m--smb-port\033[0m is set above 1024 and your OS does port-forwarding from 445 to that.\n\033[1;31mWARNING:\033[0m this protocol is DANGEROUS and buggy! Never expose to the internet!")
@@ -1322,6 +1331,7 @@ def run_argparse(
add_transcoding(ap)
add_ftp(ap)
add_webdav(ap)
+ add_tftp(ap)
add_smb(ap)
add_safety(ap)
add_salt(ap, fk_salt, ah_salt)
@@ -1375,7 +1385,7 @@ def main(argv: Optional[list[str]] = None) -> None:
if argv is None:
argv = sys.argv
- f = '\033[36mcopyparty v{} "\033[35m{}\033[36m" ({})\n{}\033[0;36m\n sqlite v{} | jinja2 v{} | pyftpd v{}\n\033[0m'
+ f = '\033[36mcopyparty v{} "\033[35m{}\033[36m" ({})\n{}\033[0;36m\n sqlite {} | jinja {} | pyftpd {} | tftp {}\n\033[0m'
f = f.format(
S_VERSION,
CODENAME,
@@ -1384,6 +1394,7 @@ def main(argv: Optional[list[str]] = None) -> None:
SQLITE_VER,
JINJA_VER,
PYFTPD_VER,
+ PARTFTPY_VER,
)
lprint(f)
diff --git a/copyparty/svchub.py b/copyparty/svchub.py
index 65d87d53..49f49068 100644
--- a/copyparty/svchub.py
+++ b/copyparty/svchub.py
@@ -133,7 +133,7 @@ class SvcHub(object):
if not self._process_config():
raise Exception(BAD_CFG)
- # for non-http clients (ftp)
+ # for non-http clients (ftp, tftp)
self.bans: dict[str, int] = {}
self.gpwd = Garda(self.args.ban_pw)
self.g404 = Garda(self.args.ban_404)
@@ -268,6 +268,12 @@ class SvcHub(object):
Daemon(self.start_ftpd, "start_ftpd")
zms += "f" if args.ftp else "F"
+ if args.tftp:
+ from .tftpd import Tftpd
+
+ self.tftpd: Optional[Tftpd] = None
+ Daemon(self.start_ftpd, "start_tftpd")
+
if args.smb:
# impacket.dcerpc is noisy about listen timeouts
sto = socket.getdefaulttimeout()
@@ -297,10 +303,12 @@ class SvcHub(object):
def start_ftpd(self) -> None:
time.sleep(30)
- if self.ftpd:
- return
- self.restart_ftpd()
+ if hasattr(self, "ftpd") and not self.ftpd:
+ self.restart_ftpd()
+
+ if hasattr(self, "tftpd") and not self.tftpd:
+ self.restart_tftpd()
def restart_ftpd(self) -> None:
if not hasattr(self, "ftpd"):
@@ -317,6 +325,17 @@ class SvcHub(object):
self.ftpd = Ftpd(self)
self.log("root", "started FTPd")
+ def restart_tftpd(self) -> None:
+ if not hasattr(self, "tftpd"):
+ return
+
+ from .tftpd import Tftpd
+
+ if self.tftpd:
+ return # todo
+
+ self.tftpd = Tftpd(self)
+
def thr_httpsrv_up(self) -> None:
time.sleep(1 if self.args.ign_ebind_all else 5)
expected = self.broker.num_workers * self.tcpsrv.nsrv
@@ -444,6 +463,7 @@ class SvcHub(object):
al.xff_re = self._ipa2re(al.xff_src)
al.ipa_re = self._ipa2re(al.ipa)
al.ftp_ipa_re = self._ipa2re(al.ftp_ipa or al.ipa)
+ al.tftp_ipa_re = self._ipa2re(al.tftp_ipa or al.ipa)
mte = ODict.fromkeys(DEF_MTE.split(","), True)
al.mte = odfusion(mte, al.mte)
diff --git a/copyparty/tcpsrv.py b/copyparty/tcpsrv.py
index 06ee6f4a..4bbea2c9 100644
--- a/copyparty/tcpsrv.py
+++ b/copyparty/tcpsrv.py
@@ -309,6 +309,7 @@ class TcpSrv(object):
self.hub.start_zeroconf()
gencert(self.log, self.args, self.netdevs)
self.hub.restart_ftpd()
+ self.hub.restart_tftpd()
def shutdown(self) -> None:
self.stopping = True
diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py
new file mode 100644
index 00000000..9155186f
--- /dev/null
+++ b/copyparty/tftpd.py
@@ -0,0 +1,241 @@
+# coding: utf-8
+from __future__ import print_function, unicode_literals
+
+try:
+ from types import SimpleNamespace
+except:
+ class SimpleNamespace(object):
+ def __init__(self, **attr):
+ self.__dict__.update(attr)
+
+import inspect
+import logging
+import os
+import stat
+
+from partftpy import TftpContexts, TftpServer, TftpStates
+from partftpy.TftpShared import TftpException
+
+from .__init__ import PY2, TYPE_CHECKING
+from .authsrv import VFS
+from .bos import bos
+from .util import Daemon, min_ex, pybin, runhook, undot
+
+if True: # pylint: disable=using-constant-test
+ from typing import Any, Union
+
+if TYPE_CHECKING:
+ from .svchub import SvcHub
+
+
+lg = logging.getLogger("tftp")
+debug, info, warning, error = (lg.debug, lg.info, lg.warning, lg.error)
+
+
+def _serverInitial(self, pkt: Any, raddress: str, rport: int) -> bool:
+ info("connection from %s:%s", raddress, rport)
+ ret = _orig_serverInitial(self, pkt, raddress, rport)
+ ptn = _hub[0].args.tftp_ipa_re
+ if ptn and not ptn.match(raddress):
+ yeet("client rejected (--tftp-ipa): %s" % (raddress,))
+ return ret
+
+# patch ipa-check into partftpd
+_hub: list["SvcHub"] = []
+_orig_serverInitial = TftpStates.TftpServerState.serverInitial
+TftpStates.TftpServerState.serverInitial = _serverInitial
+
+
+class Tftpd(object):
+ def __init__(self, hub: "SvcHub") -> None:
+ self.hub = hub
+ self.args = hub.args
+ self.asrv = hub.asrv
+ self.log = hub.log
+
+ _hub.clear()
+ _hub.append(hub)
+
+ lg.setLevel(logging.DEBUG if self.args.tftpv else logging.INFO)
+ for x in ["partftpy", "partftpy.TftpStates", "partftpy.TftpServer"]:
+ lgr = logging.getLogger(x)
+ lgr.setLevel(logging.DEBUG if self.args.tftpv else logging.INFO)
+
+ # patch vfs into partftpy
+ TftpContexts.open = self._open
+ TftpStates.open = self._open
+
+ fos = SimpleNamespace()
+ for k in os.__dict__:
+ try:
+ setattr(fos, k, getattr(os, k))
+ except:
+ pass
+ fos.access = self._access
+ fos.mkdir = self._mkdir
+ fos.unlink = self._unlink
+ fos.sep = "/"
+ TftpContexts.os = fos
+ TftpServer.os = fos
+ TftpStates.os = fos
+
+ fop = SimpleNamespace()
+ for k in os.path.__dict__:
+ try:
+ setattr(fop, k, getattr(os.path, k))
+ except:
+ pass
+ fop.abspath = self._p_abspath
+ fop.exists = self._p_exists
+ fop.isdir = self._p_isdir
+ fop.normpath = self._p_normpath
+ fos.path = fop
+
+ self._disarm(fos)
+
+ ip = next((x for x in self.args.i if ":" not in x), None)
+ if not ip:
+ 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
+
+ Daemon(self.srv.listen, "tftp", [self.ip, self.port])
+
+ # XXX TODO hook TftpContextServer.start;
+ # append tftp-ipa check at bottom and throw TftpException if not match
+
+ def nlog(self, msg: str, c: Union[int, str] = 0) -> None:
+ self.log("tftp", msg, c)
+
+ def _v2a(
+ self, caller: str, vpath: str, perms: list, *a: Any
+ ) -> tuple[VFS, str]:
+ vpath = vpath.replace("\\", "/").lstrip("/")
+ if not perms:
+ perms = [True, True]
+
+ debug('%s("%s", %s) %s\033[K\033[0m', caller, vpath, str(a), perms)
+ vfs, rem = self.asrv.vfs.get(vpath, "*", *perms)
+ return vfs, vfs.canonical(rem)
+
+ def _ls(self, vpath: str) -> Any:
+ # generate file listing if vpath is dir.txt and return as file object
+ return None
+
+ def _open(self, vpath: str, mode: str, *a: Any, **ka: Any) -> Any:
+ rd = wr = False
+ if mode == "rb":
+ rd = True
+ elif mode == "wb":
+ wr = True
+ else:
+ raise Exception("bad mode %s" % (mode,))
+
+ vfs, ap = self._v2a("open", vpath, [rd, wr])
+ if wr:
+ if "*" not in vfs.axs.uwrite:
+ yeet("blocked write; folder not world-writable: /%s" % (vpath,))
+
+ if bos.path.exists(ap) and "*" not in vfs.axs.udel:
+ yeet("blocked write; folder not world-deletable: /%s" % (vpath,))
+
+ xbu = vfs.flags.get("xbu")
+ if xbu and not runhook(
+ self.nlog, xbu, ap, vpath, "", "", 0, 0, "8.3.8.7", 0, ""
+ ):
+ yeet("blocked by xbu server config: " + vpath)
+
+ return open(ap, mode, *a, **ka)
+
+ def _mkdir(self, vpath: str, *a) -> None:
+ vfs, ap = self._v2a("mkdir", vpath, [])
+ if "*" not in vfs.axs.uwrite:
+ yeet("blocked mkdir; folder not world-writable: /%s" % (vpath,))
+
+ return bos.mkdir(ap)
+
+ def _unlink(self, vpath: str) -> None:
+ # return bos.unlink(self._v2a("stat", vpath, *a)[1])
+ vfs, ap = self._v2a(
+ "delete", vpath, [True, False, False, True]
+ )
+
+ try:
+ inf = bos.stat(ap)
+ except:
+ return
+
+ if not stat.S_ISREG(inf.st_mode) or inf.st_size:
+ yeet("attempted delete of non-empty file")
+
+ vpath = vpath.replace("\\", "/").lstrip("/")
+ self.hub.up2k.handle_rm("*", "8.3.8.7", [vpath], [], False)
+
+ def _access(self, *a: Any) -> bool:
+ return True
+
+ def _p_abspath(self, vpath: str) -> str:
+ return "/" + undot(vpath)
+
+ def _p_normpath(self, *a: Any) -> str:
+ return ""
+
+ def _p_exists(self, vpath: str) -> bool:
+ try:
+ ap = self._v2a("p.exists", vpath, [False, False])[1]
+ bos.stat(ap)
+ return True
+ except:
+ return False
+
+ def _p_isdir(self, vpath: str) -> bool:
+ try:
+ st = bos.stat(self._v2a("p.isdir", vpath, [False, False])[1])
+ ret = stat.S_ISDIR(st.st_mode)
+ return ret
+ except:
+ return False
+
+ def _hook(self, *a: Any, **ka: Any) -> None:
+ src = inspect.currentframe().f_back.f_code.co_name
+ error("\033[31m%s:hook(%s)\033[0m", src, a)
+ raise Exception("nope")
+
+ def _disarm(self, fos: SimpleNamespace) -> None:
+ fos.chmod = self._hook
+ fos.chown = self._hook
+ fos.close = self._hook
+ fos.ftruncate = self._hook
+ fos.lchown = self._hook
+ fos.link = self._hook
+ fos.listdir = self._hook
+ fos.lstat = self._hook
+ fos.open = self._hook
+ fos.remove = self._hook
+ fos.rename = self._hook
+ fos.replace = self._hook
+ fos.scandir = self._hook
+ fos.stat = self._hook
+ fos.symlink = self._hook
+ fos.truncate = self._hook
+ fos.utime = self._hook
+ fos.walk = self._hook
+
+ fos.path.expanduser = self._hook
+ fos.path.expandvars = self._hook
+ fos.path.getatime = self._hook
+ fos.path.getctime = self._hook
+ fos.path.getmtime = self._hook
+ fos.path.getsize = self._hook
+ fos.path.isabs = self._hook
+ fos.path.isfile = self._hook
+ fos.path.islink = self._hook
+ fos.path.realpath = self._hook
+
+def yeet(msg: str) -> None:
+ warning(msg)
+ raise TftpException(msg)
diff --git a/copyparty/util.py b/copyparty/util.py
index 04814517..e4295f34 100644
--- a/copyparty/util.py
+++ b/copyparty/util.py
@@ -423,16 +423,21 @@ try:
except:
PYFTPD_VER = "(None)"
+try:
+ from partftpy.__init__ import __version__ as PARTFTPY_VER
+except:
+ PARTFTPY_VER = "(None)"
+
PY_DESC = py_desc()
-VERSIONS = "copyparty v{} ({})\n{}\n sqlite v{} | jinja v{} | pyftpd v{}".format(
- S_VERSION, S_BUILD_DT, PY_DESC, SQLITE_VER, JINJA_VER, PYFTPD_VER
+VERSIONS = "copyparty v{} ({})\n{}\n sqlite {} | jinja {} | pyftpd {} | tftp {}".format(
+ S_VERSION, S_BUILD_DT, PY_DESC, SQLITE_VER, JINJA_VER, PYFTPD_VER, PARTFTPY_VER
)
-_: Any = (mp, BytesIO, quote, unquote, SQLITE_VER, JINJA_VER, PYFTPD_VER)
-__all__ = ["mp", "BytesIO", "quote", "unquote", "SQLITE_VER", "JINJA_VER", "PYFTPD_VER"]
+_: Any = (mp, BytesIO, quote, unquote, SQLITE_VER, JINJA_VER, PYFTPD_VER, PARTFTPY_VER)
+__all__ = ["mp", "BytesIO", "quote", "unquote", "SQLITE_VER", "JINJA_VER", "PYFTPD_VER", "PARTFTPY_VER"]
class Daemon(threading.Thread):
@@ -536,6 +541,8 @@ class HLog(logging.Handler):
elif record.name.startswith("impacket"):
if self.ptn_smb_ign.match(msg):
return
+ elif record.name.startswith("partftpy."):
+ record.name = record.name[9:]
self.log_func(record.name[-21:], msg, c)
diff --git a/docs/devnotes.md b/docs/devnotes.md
index 10ac2a86..49023b81 100644
--- a/docs/devnotes.md
+++ b/docs/devnotes.md
@@ -242,6 +242,7 @@ python3 -m venv .venv
pip install jinja2 strip_hints # MANDATORY
pip install mutagen # audio metadata
pip install pyftpdlib # ftp server
+pip install partftpy # tftp server
pip install impacket # smb server -- disable Windows Defender if you REALLY need this on windows
pip install Pillow pyheif-pillow-opener pillow-avif-plugin # thumbnails
pip install pyvips # faster thumbnails
diff --git a/docs/lics.txt b/docs/lics.txt
index bc155584..6f403418 100644
--- a/docs/lics.txt
+++ b/docs/lics.txt
@@ -24,6 +24,10 @@ https://github.com/giampaolo/pyftpdlib/
C: 2007 Giampaolo Rodola
L: MIT
+https://github.com/9001/partftpy
+C: 2010-2021 Michael P. Soulier
+L: MIT
+
https://github.com/nayuki/QR-Code-generator/
C: Project Nayuki
L: MIT
diff --git a/docs/versus.md b/docs/versus.md
index 50e802a5..02df6ed4 100644
--- a/docs/versus.md
+++ b/docs/versus.md
@@ -200,9 +200,10 @@ symbol legend,
| ----------------------- | - | - | - | - | - | - | - | - | - | - | - | - |
| serve https | โ | | โ | โ | โ | โ | โ | โ | โ | โ | โ | โ |
| serve webdav | โ | | | โ | โ | โ | โ | | โ | | | โ |
-| serve ftp | โ | | | | | โ | | | | | | โ |
-| serve ftps | โ | | | | | โ | | | | | | โ |
-| serve sftp | | | | | | โ | | | | | | โ |
+| serve ftp (tcp) | โ | | | | | โ | | | | | | โ |
+| serve ftps (tls) | โ | | | | | โ | | | | | | โ |
+| serve tftp (udp) | โ | | | | | | | | | | | |
+| serve sftp (ssh) | | | | | | โ | | | | | | โ |
| serve smb/cifs | โฑ | | | | | โ | | | | | | |
| serve dlna | | | | | | โ | | | | | | |
| listen on unix-socket | | | | โ | โ | | โ | โ | โ | | โ | โ |
diff --git a/pyproject.toml b/pyproject.toml
index 8c1a1020..f940c2dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,6 +48,7 @@ thumbnails2 = ["pyvips"]
audiotags = ["mutagen"]
ftpd = ["pyftpdlib"]
ftps = ["pyftpdlib", "pyopenssl"]
+tftpd = ["partftpy"]
pwhash = ["argon2-cffi"]
[project.scripts]
diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh
index c05dfa24..8a56ecab 100755
--- a/scripts/make-sfx.sh
+++ b/scripts/make-sfx.sh
@@ -26,8 +26,9 @@ help() { exec cat <<'EOF'
# _____________________________________________________________________
# core features:
#
-# `no-ftp` saves ~33k by removing the ftp server and filetype detector,
-# disabling --ftpd and --magic
+# `no-ftp` saves ~30k by removing the ftp server, disabling --ftp
+#
+# `no-tfp` saves ~10k by removing the tftp server, disabling --tftp
#
# `no-smb` saves ~3.5k by removing the smb / cifs server
#
@@ -114,6 +115,7 @@ while [ ! -z "$1" ]; do
gz) use_gz=1 ; ;;
gzz) shift;use_gzz=$1;use_gz=1; ;;
no-ftp) no_ftp=1 ; ;;
+ no-tfp) no_tfp=1 ; ;;
no-smb) no_smb=1 ; ;;
no-zm) no_zm=1 ; ;;
no-fnt) no_fnt=1 ; ;;
@@ -165,7 +167,8 @@ necho() {
[ $repack ] && {
old="$tmpdir/pe-copyparty.$(id -u)"
echo "repack of files in $old"
- cp -pR "$old/"*{py2,py37,j2,copyparty} .
+ cp -pR "$old/"*{py2,py37,magic,j2,copyparty} .
+ cp -pR "$old/"*partftpy . || true
cp -pR "$old/"*ftp . || true
}
@@ -221,6 +224,16 @@ necho() {
mkdir ftp/
mv pyftpdlib ftp/
+ necho collecting partftpy
+ f="../build/partftpy-0.1.0.tar.gz"
+ [ -e "$f" ] ||
+ (url=https://files.pythonhosted.org/packages/55/25/e043193fb3d941b91fc84a55e0560b1c248f3f04d73747eb4f35f5e2776e/partftpy-0.1.0.tar.gz;
+ wget -O$f "$url" || curl -L "$url" >$f)
+
+ tar -zxf $f
+ mv partftpy-*/partftpy .
+ rm -rf partftpy-* partftpy/bin
+
necho collecting python-magic
v=0.4.27
f="../build/python-magic-$v.tar.gz"
@@ -234,7 +247,6 @@ necho() {
rm -rf python-magic-*
rm magic/compat.py
iawk '/^def _add_compat/{o=1} !o; /^_add_compat/{o=0}' magic/__init__.py
- mv magic ftp/ # doesn't provide a version label anyways
# enable this to dynamically remove type hints at startup,
# in case a future python version can use them for performance
@@ -409,8 +421,10 @@ iawk '/^ {0,4}[^ ]/{s=0}/^ {4}def (serve_forever|_loop)/{s=1}!s' ftp/pyftpdlib/s
rm -f ftp/pyftpdlib/{__main__,prefork}.py
[ $no_ftp ] &&
- rm -rf copyparty/ftpd.py ftp &&
- sed -ri '/\.ftp/d' copyparty/svchub.py
+ rm -rf copyparty/ftpd.py ftp
+
+[ $no_tfp ] &&
+ rm -rf copyparty/tftpd.py partftpy
[ $no_smb ] &&
rm -f copyparty/smbd.py
@@ -584,7 +598,7 @@ nf=$(ls -1 "$zdir"/arc.* 2>/dev/null | wc -l)
echo gen tarlist
-for d in copyparty j2 py2 py37 ftp; do find $d -type f; done | # strip_hints
+for d in copyparty partftpy magic j2 py2 py37 ftp; do find $d -type f || true; done | # strip_hints
sed -r 's/(.*)\.(.*)/\2 \1/' | LC_ALL=C sort |
sed -r 's/([^ ]*) (.*)/\2.\1/' | grep -vE '/list1?$' > list1
diff --git a/scripts/sfx.ls b/scripts/sfx.ls
index e21fec23..407137be 100644
--- a/scripts/sfx.ls
+++ b/scripts/sfx.ls
@@ -54,6 +54,7 @@ copyparty/sutil.py,
copyparty/svchub.py,
copyparty/szip.py,
copyparty/tcpsrv.py,
+copyparty/tftpd.py,
copyparty/th_cli.py,
copyparty/th_srv.py,
copyparty/u2idx.py,
diff --git a/setup.py b/setup.py
index 2dd34184..9601970f 100755
--- a/setup.py
+++ b/setup.py
@@ -84,7 +84,7 @@ args = {
"version": about["__version__"],
"description": (
"Portable file server with accelerated resumable uploads, "
- + "deduplication, WebDAV, FTP, zeroconf, media indexer, "
+ + "deduplication, WebDAV, FTP, TFTP, zeroconf, media indexer, "
+ "video thumbnails, audio transcoding, and write-only folders"
),
"long_description": long_description,
@@ -140,6 +140,7 @@ args = {
"audiotags": ["mutagen"],
"ftpd": ["pyftpdlib"],
"ftps": ["pyftpdlib", "pyopenssl"],
+ "tftpd": ["partftpy"],
"pwhash": ["argon2-cffi"],
},
"entry_points": {"console_scripts": ["copyparty = copyparty.__main__:main"]},
From 8796c09f569872077a7a4c1831b65c4d0c668176 Mon Sep 17 00:00:00 2001
From: ed
Date: Sat, 10 Feb 2024 21:45:57 +0000
Subject: [PATCH 02/36] add `--tftp-pr` to specify portrange instead of
ephemerals
---
copyparty/__main__.py | 1 +
copyparty/tftpd.py | 10 ++++++----
pyproject.toml | 2 +-
scripts/make-sfx.sh | 4 ++--
setup.py | 2 +-
5 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index ef1540f9..3e190caf 100755
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -1020,6 +1020,7 @@ def add_tftp(ap):
ap2.add_argument("--tftpv", action="store_true", help="verbose")
ap2.add_argument("--tftpvv", action="store_true", help="verboser")
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]")
+ ap2.add_argument("--tftp-pr", metavar="P-P", type=u, help="the range of UDP ports to use for data transfer, for example \033[32m12000-13000")
def add_smb(ap):
diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py
index 9155186f..ce23b626 100644
--- a/copyparty/tftpd.py
+++ b/copyparty/tftpd.py
@@ -103,10 +103,12 @@ class Tftpd(object):
self.srv = TftpServer.TftpServer("/", self._ls)
self.stop = self.srv.stop
- Daemon(self.srv.listen, "tftp", [self.ip, self.port])
+ ports = []
+ if self.args.tftp_pr:
+ p1, p2 = [int(x) for x in self.args.tftp_pr.split("-")]
+ ports = list(range(p1, p2 + 1))
- # XXX TODO hook TftpContextServer.start;
- # append tftp-ipa check at bottom and throw TftpException if not match
+ Daemon(self.srv.listen, "tftp", [self.ip, self.port], ka={"ports": ports})
def nlog(self, msg: str, c: Union[int, str] = 0) -> None:
self.log("tftp", msg, c)
@@ -122,7 +124,7 @@ class Tftpd(object):
vfs, rem = self.asrv.vfs.get(vpath, "*", *perms)
return vfs, vfs.canonical(rem)
- def _ls(self, vpath: str) -> Any:
+ def _ls(self, vpath: str, raddress: str, rport: int) -> Any:
# generate file listing if vpath is dir.txt and return as file object
return None
diff --git a/pyproject.toml b/pyproject.toml
index f940c2dc..9e58d831 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,7 +48,7 @@ thumbnails2 = ["pyvips"]
audiotags = ["mutagen"]
ftpd = ["pyftpdlib"]
ftps = ["pyftpdlib", "pyopenssl"]
-tftpd = ["partftpy"]
+tftpd = ["partftpy>=0.2.0"]
pwhash = ["argon2-cffi"]
[project.scripts]
diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh
index 8a56ecab..44f26938 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.1.0.tar.gz"
+ f="../build/partftpy-0.2.0.tar.gz"
[ -e "$f" ] ||
- (url=https://files.pythonhosted.org/packages/55/25/e043193fb3d941b91fc84a55e0560b1c248f3f04d73747eb4f35f5e2776e/partftpy-0.1.0.tar.gz;
+ (url=https://files.pythonhosted.org/packages/64/4a/360dde1e7277758a4ccb0d6434ec661042d9d745aa6c3baa9ec0699df3e9/partftpy-0.2.0.tar.gz;
wget -O$f "$url" || curl -L "$url" >$f)
tar -zxf $f
diff --git a/setup.py b/setup.py
index 9601970f..4cc3cd2a 100755
--- a/setup.py
+++ b/setup.py
@@ -140,7 +140,7 @@ args = {
"audiotags": ["mutagen"],
"ftpd": ["pyftpdlib"],
"ftps": ["pyftpdlib", "pyopenssl"],
- "tftpd": ["partftpy"],
+ "tftpd": ["partftpy>=0.2.0"],
"pwhash": ["argon2-cffi"],
},
"entry_points": {"console_scripts": ["copyparty = copyparty.__main__:main"]},
From acbb8267e1ab7eea7f62c467d6bfd227294127b0 Mon Sep 17 00:00:00 2001
From: ed
Date: Sat, 10 Feb 2024 23:50:17 +0000
Subject: [PATCH 03/36] tftp: add directory listing
---
copyparty/__main__.py | 4 ++-
copyparty/svchub.py | 7 ++++
copyparty/tftpd.py | 84 ++++++++++++++++++++++++++++++++++++++-----
copyparty/util.py | 17 +++++++--
tests/test_dots.py | 2 +-
tests/util.py | 2 +-
6 files changed, 101 insertions(+), 15 deletions(-)
diff --git a/copyparty/__main__.py b/copyparty/__main__.py
index 3e190caf..561f33c9 100755
--- a/copyparty/__main__.py
+++ b/copyparty/__main__.py
@@ -43,10 +43,10 @@ from .util import (
DEF_MTH,
IMPLICATIONS,
JINJA_VER,
+ PARTFTPY_VER,
PY_DESC,
PYFTPD_VER,
SQLITE_VER,
- PARTFTPY_VER,
UNPLICATIONS,
align_tab,
ansi_re,
@@ -1019,6 +1019,8 @@ 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-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]")
ap2.add_argument("--tftp-pr", metavar="P-P", type=u, help="the range of UDP ports to use for data transfer, for example \033[32m12000-13000")
diff --git a/copyparty/svchub.py b/copyparty/svchub.py
index 49f49068..8bf3c645 100644
--- a/copyparty/svchub.py
+++ b/copyparty/svchub.py
@@ -451,6 +451,13 @@ class SvcHub(object):
else:
setattr(al, k, re.compile(vs))
+ for k in "tftp_lsf".split(" "):
+ vs = getattr(al, k)
+ if not vs or vs == "no":
+ setattr(al, k, None)
+ else:
+ setattr(al, k, re.compile("^" + vs + "$"))
+
if not al.sus_urls:
al.ban_url = "no"
elif al.ban_url == "no":
diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py
index ce23b626..8022b88a 100644
--- a/copyparty/tftpd.py
+++ b/copyparty/tftpd.py
@@ -4,14 +4,17 @@ from __future__ import print_function, unicode_literals
try:
from types import SimpleNamespace
except:
+
class SimpleNamespace(object):
def __init__(self, **attr):
self.__dict__.update(attr)
+
import inspect
import logging
import os
import stat
+from datetime import datetime
from partftpy import TftpContexts, TftpServer, TftpStates
from partftpy.TftpShared import TftpException
@@ -19,7 +22,7 @@ from partftpy.TftpShared import TftpException
from .__init__ import PY2, TYPE_CHECKING
from .authsrv import VFS
from .bos import bos
-from .util import Daemon, min_ex, pybin, runhook, undot
+from .util import BytesIO, Daemon, exclude_dotfiles, runhook, undot
if True: # pylint: disable=using-constant-test
from typing import Any, Union
@@ -40,6 +43,7 @@ def _serverInitial(self, pkt: Any, raddress: str, rport: int) -> bool:
yeet("client rejected (--tftp-ipa): %s" % (raddress,))
return ret
+
# patch ipa-check into partftpd
_hub: list["SvcHub"] = []
_orig_serverInitial = TftpStates.TftpServerState.serverInitial
@@ -113,9 +117,7 @@ class Tftpd(object):
def nlog(self, msg: str, c: Union[int, str] = 0) -> None:
self.log("tftp", msg, c)
- def _v2a(
- self, caller: str, vpath: str, perms: list, *a: Any
- ) -> tuple[VFS, str]:
+ def _v2a(self, caller: str, vpath: str, perms: list, *a: Any) -> tuple[VFS, str]:
vpath = vpath.replace("\\", "/").lstrip("/")
if not perms:
perms = [True, True]
@@ -124,9 +126,71 @@ class Tftpd(object):
vfs, rem = self.asrv.vfs.get(vpath, "*", *perms)
return vfs, vfs.canonical(rem)
- def _ls(self, vpath: str, raddress: str, rport: int) -> Any:
+ def _ls(self, vpath: str, raddress: str, rport: int, force=False) -> Any:
# generate file listing if vpath is dir.txt and return as file object
- return None
+ if not force:
+ vpath, fn = os.path.split(vpath.replace("\\", "/"))
+ ptn = self.args.tftp_lsf
+ if not ptn or not ptn.match(fn.lower()):
+ return None
+
+ vn, rem = self.asrv.vfs.get(vpath, "*", True, False)
+ fsroot, vfs_ls, vfs_virt = vn.ls(
+ rem,
+ "*",
+ not self.args.no_scandir,
+ [[True, False]],
+ )
+ dnames = set([x[0] for x in vfs_ls if stat.S_ISDIR(x[1].st_mode)])
+ dirs1 = [(v.st_mtime, v.st_size, k + "/") for k, v in vfs_ls if k in dnames]
+ fils1 = [(v.st_mtime, v.st_size, k) for k, v in vfs_ls if k not in dnames]
+ real1 = dirs1 + fils1
+ realt = [(datetime.fromtimestamp(mt), sz, fn) for mt, sz, fn in real1]
+ reals = [
+ (
+ "%04d-%02d-%02d %02d:%02d:%02d"
+ % (
+ zd.year,
+ zd.month,
+ zd.day,
+ zd.hour,
+ zd.minute,
+ zd.second,
+ ),
+ sz,
+ fn,
+ )
+ for zd, sz, fn in realt
+ ]
+ virs = [("????-??-?? ??:??:??", 0, k + "/") for k in vfs_virt.keys()]
+ ls = virs + reals
+
+ if "*" not in vn.axs.udot:
+ names = set(exclude_dotfiles([x[2] for x in ls]))
+ ls = [x for x in ls if x[2] in names]
+
+ try:
+ biggest = max([x[1] for x in ls])
+ except:
+ biggest = 0
+
+ perms = []
+ if "*" in vn.axs.uread:
+ perms.append("read")
+ if "*" in vn.axs.udot:
+ perms.append("hidden")
+ if "*" in vn.axs.uwrite:
+ if "*" in vn.axs.udel:
+ perms.append("overwrite")
+ else:
+ perms.append("write")
+
+ fmt = "{{}} {{:{},}} {{}}"
+ fmt = fmt.format(len("{:,}".format(biggest)))
+ retl = ["# permissions: %s" % (", ".join(perms),)]
+ retl += [fmt.format(*x) for x in ls]
+ ret = "\n".join(retl).encode("utf-8", "replace")
+ return BytesIO(ret)
def _open(self, vpath: str, mode: str, *a: Any, **ka: Any) -> Any:
rd = wr = False
@@ -151,6 +215,9 @@ class Tftpd(object):
):
yeet("blocked by xbu server config: " + vpath)
+ if not self.args.tftp_nols and bos.path.isdir(ap):
+ return self._ls(vpath, "", 0, True)
+
return open(ap, mode, *a, **ka)
def _mkdir(self, vpath: str, *a) -> None:
@@ -162,9 +229,7 @@ class Tftpd(object):
def _unlink(self, vpath: str) -> None:
# return bos.unlink(self._v2a("stat", vpath, *a)[1])
- vfs, ap = self._v2a(
- "delete", vpath, [True, False, False, True]
- )
+ vfs, ap = self._v2a("delete", vpath, [True, False, False, True])
try:
inf = bos.stat(ap)
@@ -238,6 +303,7 @@ class Tftpd(object):
fos.path.islink = self._hook
fos.path.realpath = self._hook
+
def yeet(msg: str) -> None:
warning(msg)
raise TftpException(msg)
diff --git a/copyparty/util.py b/copyparty/util.py
index e4295f34..e3b7c6b1 100644
--- a/copyparty/util.py
+++ b/copyparty/util.py
@@ -431,13 +431,24 @@ except:
PY_DESC = py_desc()
-VERSIONS = "copyparty v{} ({})\n{}\n sqlite {} | jinja {} | pyftpd {} | tftp {}".format(
- S_VERSION, S_BUILD_DT, PY_DESC, SQLITE_VER, JINJA_VER, PYFTPD_VER, PARTFTPY_VER
+VERSIONS = (
+ "copyparty v{} ({})\n{}\n sqlite {} | jinja {} | pyftpd {} | tftp {}".format(
+ S_VERSION, S_BUILD_DT, PY_DESC, SQLITE_VER, JINJA_VER, PYFTPD_VER, PARTFTPY_VER
+ )
)
_: Any = (mp, BytesIO, quote, unquote, SQLITE_VER, JINJA_VER, PYFTPD_VER, PARTFTPY_VER)
-__all__ = ["mp", "BytesIO", "quote", "unquote", "SQLITE_VER", "JINJA_VER", "PYFTPD_VER", "PARTFTPY_VER"]
+__all__ = [
+ "mp",
+ "BytesIO",
+ "quote",
+ "unquote",
+ "SQLITE_VER",
+ "JINJA_VER",
+ "PYFTPD_VER",
+ "PARTFTPY_VER",
+]
class Daemon(threading.Thread):
diff --git a/tests/test_dots.py b/tests/test_dots.py
index 5822dfdd..3e8e60a0 100644
--- a/tests/test_dots.py
+++ b/tests/test_dots.py
@@ -11,8 +11,8 @@ import unittest
from copyparty.authsrv import AuthSrv
from copyparty.httpcli import HttpCli
-from copyparty.up2k import Up2k
from copyparty.u2idx import U2idx
+from copyparty.up2k import Up2k
from tests import util as tu
from tests.util import Cfg
diff --git a/tests/util.py b/tests/util.py
index 60b955da..a91c1cce 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -43,8 +43,8 @@ if MACOS:
from copyparty.__init__ import E
from copyparty.__main__ import init_E
-from copyparty.util import FHC, Garda, Unrecv
from copyparty.u2idx import U2idx
+from copyparty.util import FHC, Garda, Unrecv
init_E(E)
From 02879713a29ef26de9799d28639531c6b9614fbe Mon Sep 17 00:00:00 2001
From: ed
Date: Mon, 12 Feb 2024 05:39:54 +0100
Subject: [PATCH 04/36] tftp: update readme + small py2 fix
---
README.md | 13 +++++++++----
copyparty/tftpd.py | 2 +-
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 37c1bb94..9429a6cc 100644
--- a/README.md
+++ b/README.md
@@ -947,19 +947,24 @@ known client bugs:
## tftp server
-a TFTP server (read/write) can be started using `--tftp 3969` (you probably want [ftp](#ftp-server) instead unless you are *actually* communicating with hardware from the 80s (in which case we should definitely hang some time))
+a TFTP server (read/write) can be started using `--tftp 3969` (you probably want [ftp](#ftp-server) instead unless you are *actually* communicating with hardware from the 90s (in which case we should definitely hang some time))
+
+> that makes this the first RTX DECT Base that has been updated using copyparty ๐
* 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)
-* no accounts; read from world-readable folders, write to world-writable, overwrite in world-deletable
+* 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 (will be extremely slow over WAN)
+ * expect ~1100 KiB/s over 1000BASE-T, 400~500 KiB/s over wifi, ~200 on bad wifi
some recommended TFTP clients:
* windows: `tftp.exe` (you probably already have it)
* linux: `tftp-hpa`, `atftp`
- * `tftp 127.0.0.1 3969 -v -m binary -c put initrd.bin`
-* `curl` (read-only)
+ * `tftp 127.0.0.1 3969 -v -m binary -c put firmware.bin`
+* `curl tftp://127.0.0.1:3969/firmware.bin` (read-only)
## smb server
diff --git a/copyparty/tftpd.py b/copyparty/tftpd.py
index 8022b88a..62d29144 100644
--- a/copyparty/tftpd.py
+++ b/copyparty/tftpd.py
@@ -57,7 +57,7 @@ class Tftpd(object):
self.asrv = hub.asrv
self.log = hub.log
- _hub.clear()
+ _hub[:] = []
_hub.append(hub)
lg.setLevel(logging.DEBUG if self.args.tftpv else logging.INFO)
From f7a43a8e4605634d307585ec5c4a860d8f04b6cd Mon Sep 17 00:00:00 2001
From: ed
Date: Mon, 12 Feb 2024 05:40:18 +0100
Subject: [PATCH 05/36] fix grid layout on first toggle from listview
---
copyparty/web/browser.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 562a05a3..655b7c6f 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -4812,6 +4812,7 @@ var thegrid = (function () {
r.dirty = false;
r.bagit('#ggrid');
r.loadsel();
+ aligngriditems();
setTimeout(r.tippen, 20);
}
From 7c8e368721f06bf961f0f69b86fd948a81191f52 Mon Sep 17 00:00:00 2001
From: ed
Date: Mon, 12 Feb 2024 06:01:09 +0100
Subject: [PATCH 06/36] lol markdown
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 9429a6cc..14c089aa 100644
--- a/README.md
+++ b/README.md
@@ -957,8 +957,8 @@ a TFTP server (read/write) can be started using `--tftp 3969` (you probably wan
* run as root 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 (will be extremely slow over WAN)
- * expect ~1100 KiB/s over 1000BASE-T, 400~500 KiB/s over wifi, ~200 on bad wifi
+* [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
some recommended TFTP clients:
* windows: `tftp.exe` (you probably already have it)
From 6f8a588c4d473069baa4e42c1caef028a6bf4e98 Mon Sep 17 00:00:00 2001
From: ed
Date: Tue, 13 Feb 2024 19:24:06 +0000
Subject: [PATCH 07/36] up2k: fix a mostly-harmless race
as each chunk is written to the file, httpcli calls
up2k.confirm_chunk to register the chunk as completed, and the reply
indicates whether that was the final outstanding chunk, in which case
httpcli closes the file descriptors since there's nothing more to write
the issue is that the final chunk is registered as completed before the
file descriptors are closed, meaning there could be writes that haven't
finished flushing to disk yet
if the client decides to issue another handshake during this window,
up2k sees that all chunks are complete and calls up2k.finish_upload
even as some threads might still be flushing the final writes to disk
so the conditions to hit this bug were as follows (all must be true):
* multiprocessing is disabled
* there is a reverse-proxy
* a client has several idle connections and reuses one of those
* the server's filesystem is EXTREMELY slow, to the point where
closing a file takes over 30 seconds
the fix is to stop handshakes from being processed while a file is
being closed, which is unfortunately a small bottleneck in that it
prohibits initiating another upload while one is being finalized, but
the required complexity to handle this better is probably not worth it
(a separate mutex for each upload session or something like that)
this issue is mostly harmless, partially because it is super tricky to
hit (only aware of it happening synthetically), and because there is
usually no harmful consequences; the worst-case is if this were to
happen exactly as the server OS decides to crash, which would make the
file appear to be fully uploaded even though it's missing some data
(all extremely unlikely, but not impossible)
there is no performance impact; if anything it should now accept
new tcp connections slightly faster thanks to more granular locking
---
copyparty/httpcli.py | 15 +++++++++------
copyparty/httpconn.py | 2 +-
copyparty/httpsrv.py | 3 ++-
tests/util.py | 1 +
4 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py
index ef7b7908..153a6763 100644
--- a/copyparty/httpcli.py
+++ b/copyparty/httpcli.py
@@ -115,7 +115,7 @@ class HttpCli(object):
self.t0 = time.time()
self.conn = conn
- self.mutex = conn.mutex # mypy404
+ self.u2mutex = conn.u2mutex # mypy404
self.s = conn.s
self.sr = conn.sr
self.ip = conn.addr[0]
@@ -1988,8 +1988,11 @@ class HttpCli(object):
except:
raise Pebkac(500, min_ex())
- x = self.conn.hsrv.broker.ask("up2k.handle_json", body, self.u2fh.aps)
- ret = x.get()
+ # not to protect u2fh, but to prevent handshakes while files are closing
+ with self.u2mutex:
+ x = self.conn.hsrv.broker.ask("up2k.handle_json", body, self.u2fh.aps)
+ ret = x.get()
+
if self.is_vproxied:
if "purl" in ret:
ret["purl"] = self.args.SR + ret["purl"]
@@ -2094,7 +2097,7 @@ class HttpCli(object):
f = None
fpool = not self.args.no_fpool and sprs
if fpool:
- with self.mutex:
+ with self.u2mutex:
try:
f = self.u2fh.pop(path)
except:
@@ -2137,7 +2140,7 @@ class HttpCli(object):
if not fpool:
f.close()
else:
- with self.mutex:
+ with self.u2mutex:
self.u2fh.put(path, f)
except:
# maybe busted handle (eg. disk went full)
@@ -2156,7 +2159,7 @@ class HttpCli(object):
return False
if not num_left and fpool:
- with self.mutex:
+ with self.u2mutex:
self.u2fh.close(path)
if not num_left and not self.args.nw:
diff --git a/copyparty/httpconn.py b/copyparty/httpconn.py
index 90f40a93..bf3690ab 100644
--- a/copyparty/httpconn.py
+++ b/copyparty/httpconn.py
@@ -50,7 +50,7 @@ class HttpConn(object):
self.addr = addr
self.hsrv = hsrv
- self.mutex: threading.Lock = hsrv.mutex # mypy404
+ self.u2mutex: threading.Lock = hsrv.u2mutex # mypy404
self.args: argparse.Namespace = hsrv.args # mypy404
self.E: EnvParams = self.args.E
self.asrv: AuthSrv = hsrv.asrv # mypy404
diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py
index 6e7daf9e..40bd108f 100644
--- a/copyparty/httpsrv.py
+++ b/copyparty/httpsrv.py
@@ -117,6 +117,7 @@ class HttpSrv(object):
self.bound: set[tuple[str, int]] = set()
self.name = "hsrv" + nsuf
self.mutex = threading.Lock()
+ self.u2mutex = threading.Lock()
self.stopping = False
self.tp_nthr = 0 # actual
@@ -220,7 +221,7 @@ class HttpSrv(object):
def periodic(self) -> None:
while True:
time.sleep(2 if self.tp_ncli or self.ncli else 10)
- with self.mutex:
+ with self.u2mutex, self.mutex:
self.u2fh.clean()
if self.tp_q:
self.tp_ncli = max(self.ncli, self.tp_ncli - 2)
diff --git a/tests/util.py b/tests/util.py
index a91c1cce..3f0967ee 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -243,6 +243,7 @@ class VHttpConn(object):
self.log_func = log
self.log_src = "a"
self.mutex = threading.Lock()
+ self.u2mutex = threading.Lock()
self.nbyte = 0
self.nid = None
self.nreq = -1
From 5d92f4df490c54c7086f67e4237b37c44a1b77e5 Mon Sep 17 00:00:00 2001
From: ed
Date: Tue, 13 Feb 2024 19:47:42 +0000
Subject: [PATCH 08/36] mention why `-j0` can be a bad idea to enable,
and that `--hist` can also help for loading thumbnails faster
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 14c089aa..e0793acc 100644
--- a/README.md
+++ b/README.md
@@ -1697,6 +1697,7 @@ below are some tweaks roughly ordered by usefulness:
* `-q` disables logging and can help a bunch, even when combined with `-lo` to redirect logs to file
* `--hist` pointing to a fast location (ssd) will make directory listings and searches faster when `-e2d` or `-e2t` is set
+ * and also makes thumbnails load faster, regardless of e2d/e2t
* `--no-hash .` when indexing a network-disk if you don't care about the actual filehashes and only want the names/tags searchable
* `--no-htp --hash-mt=0 --mtag-mt=1 --th-mt=1` minimizes the number of threads; can help in some eccentric environments (like the vscode debugger)
* `-j0` enables multiprocessing (actual multithreading), can reduce latency to `20+80/numCores` percent and generally improve performance in cpu-intensive workloads, for example:
@@ -1704,7 +1705,7 @@ below are some tweaks roughly ordered by usefulness:
* simultaneous downloads and uploads saturating a 20gbps connection
* if `-e2d` is enabled, `-j2` gives 4x performance for directory listings; `-j4` gives 16x
- ...however it adds an overhead to internal communication so it might be a net loss, see if it works 4 u
+ ...however it also increases the server/filesystem/HDD load during uploads, and adds an overhead to internal communication, so it is usually a better idea to don't
* using [pypy](https://www.pypy.org/) instead of [cpython](https://www.python.org/) *can* be 70% faster for some workloads, but slower for many others
* and pypy can sometimes crash on startup with `-j0` (TODO make issue)
From d4da386172947289cf30e18e9e6d5dc3dde4102b Mon Sep 17 00:00:00 2001
From: ed
Date: Wed, 14 Feb 2024 20:18:36 +0000
Subject: [PATCH 09/36] add watchdog for sqlite deadlock on db init:
some cifs servers cause sqlite to fail in interesting ways; any attempt
to create a table can instantly throw an exception, which results in a
zerobyte database being created. During the next startup, the db would
be determined to be corrupted, and up2k would invoke _backup_db before
deleting and recreating it -- except that sqlite's connection.backup()
will hang indefinitely and deadlock up2k
add a watchdog which fires if it takes longer than 1 minute to open the
database, printing a big warning that the filesystem probably does not
support locking or is otherwise sqlite-incompatible, then writing a
stacktrace of all threads to a textfile in the config directory
(in case this deadlock is due to something completely different),
before finally crashing spectacularly
additionally, delete the database if the creation fails, which should
prevents the deadlock on the next startup, so combine that with a
message hinting at the filesystem incompatibility
the 1-minute limit may sound excessively gracious, but considering what
some of the copyparty instances out there is running on, really isn't
this was reported when connecting to a cifs server running alpine
thx to abex on discord for the detailed bug report!
---
copyparty/up2k.py | 78 ++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 71 insertions(+), 7 deletions(-)
diff --git a/copyparty/up2k.py b/copyparty/up2k.py
index cb2ad273..f71e5a6e 100644
--- a/copyparty/up2k.py
+++ b/copyparty/up2k.py
@@ -21,7 +21,7 @@ from copy import deepcopy
from queue import Queue
-from .__init__ import ANYWIN, PY2, TYPE_CHECKING, WINDOWS
+from .__init__ import ANYWIN, PY2, TYPE_CHECKING, WINDOWS, E
from .authsrv import LEELOO_DALLAS, SSEELOG, VFS, AuthSrv
from .bos import bos
from .cfg import vf_bmap, vf_cmap, vf_vmap
@@ -35,6 +35,7 @@ from .util import (
Pebkac,
ProgressPrinter,
absreal,
+ alltrace,
atomic_move,
db_ex_chk,
dir_is_empty,
@@ -87,6 +88,9 @@ zsg = "avif,avifs,bmp,gif,heic,heics,heif,heifs,ico,j2p,j2k,jp2,jpeg,jpg,jpx,png
CV_EXTS = set(zsg.split(","))
+HINT_HISTPATH = "you could try moving the database to another location (preferably an SSD or NVME drive) using either the --hist argument (global option for all volumes), or the hist volflag (just for this volume)"
+
+
class Dbw(object):
def __init__(self, c: "sqlite3.Cursor", n: int, t: float) -> None:
self.c = c
@@ -892,7 +896,7 @@ class Up2k(object):
return None
try:
- cur = self._open_db(db_path)
+ cur = self._open_db_wd(db_path)
# speeds measured uploading 520 small files on a WD20SPZX (SMR 2.5" 5400rpm 4kb)
dbd = flags["dbd"]
@@ -935,8 +939,8 @@ class Up2k(object):
return cur, db_path
except:
- msg = "cannot use database at [{}]:\n{}"
- self.log(msg.format(ptop, traceback.format_exc()))
+ msg = "ERROR: cannot use database at [%s]:\n%s\n\033[33mhint: %s\n"
+ self.log(msg % (db_path, traceback.format_exc(), HINT_HISTPATH), 1)
return None
@@ -2155,6 +2159,46 @@ class Up2k(object):
def _trace(self, msg: str) -> None:
self.log("ST: {}".format(msg))
+ def _open_db_wd(self, db_path: str) -> "sqlite3.Cursor":
+ ok: list[int] = []
+ Daemon(self._open_db_timeout, "opendb_watchdog", [db_path, ok])
+ try:
+ return self._open_db(db_path)
+ finally:
+ ok.append(1)
+
+ def _open_db_timeout(self, db_path, ok: list[int]) -> None:
+ # give it plenty of time due to the count statement (and wisdom from byte's box)
+ for _ in range(60):
+ time.sleep(1)
+ if ok:
+ return
+
+ t = "WARNING:\n\n initializing an up2k database is taking longer than one minute; something has probably gone wrong:\n\n"
+ self._log_sqlite_incompat(db_path, t)
+
+ def _log_sqlite_incompat(self, db_path, t0) -> None:
+ txt = t0 or ""
+ digest = hashlib.sha512(db_path.encode("utf-8", "replace")).digest()
+ stackname = base64.urlsafe_b64encode(digest[:9]).decode("utf-8")
+ stackpath = os.path.join(E.cfg, "stack-%s.txt" % (stackname,))
+
+ t = " the filesystem at %s may not support locking, or is otherwise incompatible with sqlite\n\n %s\n\n"
+ t += " PS: if you think this is a bug and wish to report it, please include your configuration + the following file: %s\n"
+ txt += t % (db_path, HINT_HISTPATH, stackpath)
+ self.log(txt, 3)
+
+ try:
+ stk = alltrace()
+ with open(stackpath, "wb") as f:
+ f.write(stk.encode("utf-8", "replace"))
+ except Exception as ex:
+ self.log("warning: failed to write %s: %s" % (stackpath, ex), 3)
+
+ if self.args.q:
+ t = "-" * 72
+ raise Exception("%s\n%s\n%s" % (t, txt, t))
+
def _orz(self, db_path: str) -> "sqlite3.Cursor":
c = sqlite3.connect(
db_path, timeout=self.timeout, check_same_thread=False
@@ -2167,7 +2211,7 @@ class Up2k(object):
cur = self._orz(db_path)
ver = self._read_ver(cur)
if not existed and ver is None:
- return self._create_db(db_path, cur)
+ return self._try_create_db(db_path, cur)
if ver == 4:
try:
@@ -2205,8 +2249,16 @@ class Up2k(object):
db = cur.connection
cur.close()
db.close()
- bos.unlink(db_path)
- return self._create_db(db_path, None)
+ self._delete_db(db_path)
+ return self._try_create_db(db_path, None)
+
+ def _delete_db(self, db_path: str):
+ for suf in ("", "-shm", "-wal", "-journal"):
+ try:
+ bos.unlink(db_path + suf)
+ except:
+ if not suf:
+ raise
def _backup_db(
self, db_path: str, cur: "sqlite3.Cursor", ver: Optional[int], msg: str
@@ -2243,6 +2295,18 @@ class Up2k(object):
return int(rows[0][0])
return None
+ def _try_create_db(
+ self, db_path: str, cur: Optional["sqlite3.Cursor"]
+ ) -> "sqlite3.Cursor":
+ try:
+ return self._create_db(db_path, cur)
+ except:
+ try:
+ self._delete_db(db_path)
+ except:
+ pass
+ raise
+
def _create_db(
self, db_path: str, cur: Optional["sqlite3.Cursor"]
) -> "sqlite3.Cursor":
From f262aee8008dd9b9ae37c563991ff0d9e2057b08 Mon Sep 17 00:00:00 2001
From: ed
Date: Wed, 14 Feb 2024 22:44:33 +0000
Subject: [PATCH 10/36] change folders to preload music when necessary:
on phones especially, hitting the end of a folder while playing music
could permanently stop audio playback, because the browser will
revoke playback privileges unless we have a song ready to go...
there's no time to navigate through folders looking for the next file
the preloader will now start jumping through folders ahead of time
---
copyparty/web/browser.css | 4 +++-
copyparty/web/browser.js | 50 +++++++++++++++++++++++++++++++++++----
2 files changed, 49 insertions(+), 5 deletions(-)
diff --git a/copyparty/web/browser.css b/copyparty/web/browser.css
index 656fdb50..737e0a0f 100644
--- a/copyparty/web/browser.css
+++ b/copyparty/web/browser.css
@@ -1775,6 +1775,7 @@ html.y #tree.nowrap .ntree a+a:hover {
padding: 0;
}
#thumbs,
+#au_prescan,
#au_fullpre,
#au_os_seek,
#au_osd_cv,
@@ -1782,7 +1783,8 @@ html.y #tree.nowrap .ntree a+a:hover {
opacity: .3;
}
#griden.on+#thumbs,
-#au_preload.on+#au_fullpre,
+#au_preload.on+#au_prescan,
+#au_preload.on+#au_prescan+#au_fullpre,
#au_os_ctl.on+#au_os_seek,
#au_os_ctl.on+#au_os_seek+#au_osd_cv,
#u2turbo.on+#u2tdate {
diff --git a/copyparty/web/browser.js b/copyparty/web/browser.js
index 655b7c6f..932337bb 100644
--- a/copyparty/web/browser.js
+++ b/copyparty/web/browser.js
@@ -240,6 +240,7 @@ var Ls = {
"mt_shuf": "shuffle the songs in each folder\">๐",
"mt_preload": "start loading the next song near the end for gapless playback\">preload",
+ "mt_prescan": "go to the next folder before the last song$Nends, keeping the webbrowser happy$Nso it doesn't stop the playback\">nav",
"mt_fullpre": "try to preload the entire song;$Nโ
enable on unreliable connections,$Nโ disable on slow connections probably\">full",
"mt_waves": "waveform seekbar:$Nshow audio amplitude in the scrubber\">~s",
"mt_npclip": "show buttons for clipboarding the currently playing song\">/np",
@@ -272,6 +273,8 @@ var Ls = {
"mm_e403": "Could not play audio; error 403: Access denied.\n\nTry pressing F5 to reload, maybe you got logged out",
"mm_e5xx": "Could not play audio; server error ",
"mm_nof": "not finding any more audio files nearby",
+ "mm_prescan": "Looking for music to play next...",
+ "mm_scank": "Found the next song:",
"mm_uncache": "cache cleared; all songs will redownload on next playback",
"mm_pwrsv": "
it looks like playback is being interrupted by your phone's power-saving settings!
" + 'please go to the app settings of your browser and then allow unrestricted battery usage to fix it.
however, it could also be due to the browser\'s autoplay settings;
Firefox: tap the icon on the left side of the address bar, then select "autoplay" and "allow audio"
Chrome: the problem will gradually dissipate as you play more music on this site
', "mm_iosblk": "your web browser thinks the audio playback is unwanted, and it decided to block playback until you start another track manually... unfortunately we are both powerless in telling it otherwise
supposedly this will get better as you continue playing music on this site, but I'm unfamiliar with apple devices so idk if that's true
you could try another browser, maybe firefox or chrome?
", @@ -732,6 +735,7 @@ var Ls = { "mt_shuf": "sangene i hver mappe$Nspilles i tilfeldig rekkefรธlge\">๐", "mt_preload": "hent ned litt av neste sang i forkant,$Nslik at pausen i overgangen blir mindre\">forles", + "mt_prescan": "ved behov, bla til neste mappe$Nslik at nettleseren lar oss$Nfortsette รฅ spille musikk\">bla", "mt_fullpre": "hent ned hele neste sang, ikke bare litt:$Nโ skru pรฅ hvis nettet ditt er ustabilt,$Nโ skru av hvis nettet ditt er tregt\">full", "mt_waves": "waveform seekbar:$Nvis volumkurve i avspillingsfeltet\">~s", "mt_npclip": "vis knapper for รฅ kopiere info om sangen du hรธrer pรฅ\">/np", @@ -764,6 +768,8 @@ var Ls = { "mm_e403": "Avspilling feilet: Tilgang nektet.\n\nKanskje du ble logget ut?\nPrรธv รฅ trykk F5 for รฅ laste siden pรฅ nytt.", "mm_e5xx": "Avspilling feilet: ", "mm_nof": "finner ikke flere sanger i nรฆrheten", + "mm_prescan": "Leter etter neste sang...", + "mm_scank": "Fant neste sang:", "mm_uncache": "alle sanger vil lastes pรฅ nytt ved neste avspilling", "mm_pwrsv": "det ser ut som musikken ble avbrutt av telefonen sine strรธmsparings-innstillinger!
" + 'ta en tur innom app-innstillingene til nettleseren din og sรฅ tillat ubegrenset batteriforbruk
NB: det kan ogsรฅ vรฆre pga. autoplay-innstillingene, sรฅ prรธv dette:
Firefox: klikk pรฅ ikonet i venstre side av addressefeltet, velg "autoplay" og "tillat lyd"
Chrome: problemet vil minske gradvis jo mer musikk du spiller pรฅ denne siden
', "mm_iosblk": "nettleseren din tror at musikken er uรธnsket, og den bestemte seg for รฅ stoppe avspillingen slik at du manuelt mรฅ velge en ny sang... dessverre er bรฅde du og jeg makteslรธse nรฅr den har bestemt seg.
det ryktes at problemet vil minske jo mer musikk du spiller pรฅ denne siden, men jeg er ikke godt kjent med apple-dingser sรฅ jeg er ikke sikker.
kanskje firefox eller chrome fungerer bedre?
", @@ -1391,6 +1397,7 @@ var mpl = (function () { '