From f3a501db3083389744d8c4fc051b6351c053fba5 Mon Sep 17 00:00:00 2001 From: ed Date: Sun, 23 Oct 2022 23:08:00 +0200 Subject: [PATCH] add SMB/CIFS server --- README.md | 41 +++++++- copyparty/__main__.py | 13 ++- copyparty/authsrv.py | 4 +- copyparty/bos/bos.py | 6 +- copyparty/dxml.py | 3 +- copyparty/ftpd.py | 5 +- copyparty/httpcli.py | 13 ++- copyparty/httpsrv.py | 2 +- copyparty/smbd.py | 215 +++++++++++++++++++++++++++++++++++++++ copyparty/svchub.py | 31 +++++- copyparty/util.py | 5 +- scripts/sfx.ls | 1 + scripts/strip_hints/a.py | 2 +- setup.py | 4 + 14 files changed, 325 insertions(+), 20 deletions(-) create mode 100644 copyparty/smbd.py diff --git a/README.md b/README.md index 6900a847..9805e6bc 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ try the **[read-only demo server](https://a.ocv.me/pub/demo/)** 👀 running fro * [qr-code](#qr-code) - print a qr-code [(screenshot)](https://user-images.githubusercontent.com/241032/194728533-6f00849b-c6ac-43c6-9359-83e454d11e00.png) for quick access * [ftp server](#ftp-server) - an FTP server can be started using `--ftp 3921` * [webdav server](#webdav-server) - enable with `--dav` + * [smb server](#smb-server) - unsafe, not recommended for wan * [file indexing](#file-indexing) - enables dedup and music search ++ * [exclude-patterns](#exclude-patterns) - to save some time * [filesystem guards](#filesystem-guards) - avoid traversing into other filesystems @@ -168,7 +169,9 @@ feature summary * ☑ multiprocessing (actual multithreading) * ☑ volumes (mountpoints) * ☑ [accounts](#accounts-and-volumes) - * ☑ [ftp-server](#ftp-server) + * ☑ [ftp server](#ftp-server) + * ☑ [webdav server](#webdav-server) + * ☑ [smb/cifs server](#smb-server) * ☑ [qr-code](#qr-code) for quick access * upload * ☑ basic: plain multipart, ie6 support @@ -735,6 +738,39 @@ known client bugs: * latin-1 is fine, hiragana is not (not even as shift-jis on japanese xp) +## smb server + +unsafe, not recommended for wan, enable with `--smb` for read-only or `--smbw` for read-write + +dependencies: `python3 -m pip install --user -U impacket==0.10.0` +* newer versions of impacket will hopefully work just fine but there is monkeypatching so maybe not + +some big warnings specific to SMB/CIFS, in decreasing importance: +* not entirely confident that read-only is read-only +* the smb backend is not fully integrated with vfs, meaning there could be security issues (path traversal). Please use `--smb-port` (see below) and [./bin#prisonpartysh](prisonparty) + * account passwords work per-volume as expected, but account permissions are ignored; all accounts have access to all volumes, and `--smbw` gives all accounts write-access everywhere + * shadowing (hiding the contents in subfolders by creating overlapping volumes) probably works as expected but no guarantees + +and some minor issues, +* files are not [indexed](#file-indexing) when uploaded through smb; please [schedule rescans](#periodic-rescan) as a workaround +* hot-reload of server config (`/?reload=cfg`) only works for volumes, not account passwords +* listens on the first `-i` interface only (default = 0.0.0.0 = all) +* login doesn't work on winxp, but anonymous access is ok -- remove all accounts from copyparty config for that to work +* python3 only +* slow + +known client bugs: +* on win7 only, `--smb1` is much faster than smb2 (default) because it keeps rescanning folders on smb2, however win10 onwards does not have smb1 +* 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; +* on linux: `iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 445 -j REDIRECT --to-port 3945` + +authenticate with one of the following: +* username `$username`, password `$password` +* username `$password`, password blank + + ## file indexing enables dedup and music search ++ @@ -1317,6 +1353,9 @@ enable [thumbnails](#thumbnails) of... * **AVIF pictures:** `pyvips` or `ffmpeg` or `pillow-avif-plugin` * **JPEG XL pictures:** `pyvips` or `ffmpeg` +enable [smb](#smb-server) support: +* `impacket==0.10.0` + `pyvips` gives higher quality thumbnails than `Pillow` and is 320% faster, using 270% more ram: `sudo apt install libvips42 && python3 -m pip install --user -U pyvips` diff --git a/copyparty/__main__.py b/copyparty/__main__.py index 096c8311..5fece2c9 100755 --- a/copyparty/__main__.py +++ b/copyparty/__main__.py @@ -633,11 +633,18 @@ def run_argparse(argv: list[str], formatter: Any, retry: bool) -> argparse.Names ap2.add_argument("--ftp-pr", metavar="P-P", type=u, help="the range of TCP ports to use for passive connections, for example \033[32m12000-13000") ap2 = ap.add_argument_group('WebDAV options') - ap2.add_argument("--dav", action="store_true", help="enable webdav; read-only even if user has write-access") + ap2.add_argument("--dav", action="store_true", help="enable webdav with limited write-support (most clients will fail to overwrite files)") ap2.add_argument("--daw", action="store_true", help="enable full write support. \033[1;31mNB!\033[0m This has side-effects -- PUT-operations will now \033[1;31mOVERWRITE\033[0m existing files, rather than inventing new filenames to avoid loss of data. You might want to instead set this as a volflag where needed. By not setting this flag, uploaded files can get written to a filename which the client does not expect (which might be okay, depending on client)") ap2.add_argument("--dav-nr", action="store_true", help="reject depth:infinite requests (recursive file listing); breaks spec compliance and some clients, which might be a good thing since depth:infinite is extremely server-heavy") ap2.add_argument("--dav-mac", action="store_true", help="disable apple-garbage filter -- allow macos to create junk files (._* and .DS_Store, .Spotlight-*, .fseventsd, .Trashes, .AppleDouble, __MACOS)") + 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 --smb-port") + ap2.add_argument("--smbw", action="store_true", help="enable write support (please dont)") + ap2.add_argument("--smb1", action="store_true", help="disable SMBv2, only enable SMBv1 (CIFS)") + ap2.add_argument("--smb-port", metavar="PORT", type=int, default=445, help="port to listen on -- if you change this value, you must NAT from TCP:445 to this port using iptables or similar") + ap2.add_argument("--smb-dbg", action="store_true", help="show debug messages") + ap2 = ap.add_argument_group('opt-outs') ap2.add_argument("-nw", action="store_true", help="never write anything to disk (debug/benchmark)") ap2.add_argument("--keep-qem", action="store_true", help="do not disable quick-edit-mode on windows (it is disabled to avoid accidental text selection which will deadlock copyparty)") @@ -949,6 +956,10 @@ def main(argv: Optional[list[str]] = None) -> None: + " (if you crash with codec errors then that is why)" ) + if PY2 and al.smb: + print("error: python2 cannot --smb") + return + if sys.version_info < (3, 6): al.no_scandir = True diff --git a/copyparty/authsrv.py b/copyparty/authsrv.py index 6e34752c..01562bce 100644 --- a/copyparty/authsrv.py +++ b/copyparty/authsrv.py @@ -592,7 +592,7 @@ class VFS(object): # if single folder: the folder itself is the top-level item folder = "" if flt or not wrap else (vrem.split("/")[-1] or "top") - g = self.walk(folder, vrem, [], uname, [[True]], dots, scandir, False) + g = self.walk(folder, vrem, [], uname, [[True, False]], dots, scandir, False) for _, _, vpath, apath, files, rd, vd in g: if flt: files = [x for x in files if x[0] in flt] @@ -1370,7 +1370,7 @@ class AuthSrv(object): "", [], u, - [[True]], + [[True, False]], True, not self.args.no_scandir, False, diff --git a/copyparty/bos/bos.py b/copyparty/bos/bos.py index 617545af..52263652 100644 --- a/copyparty/bos/bos.py +++ b/copyparty/bos/bos.py @@ -7,7 +7,7 @@ from ..util import SYMTIME, fsdec, fsenc from . import path try: - from typing import Optional + from typing import Any, Optional except: pass @@ -38,6 +38,10 @@ def mkdir(p: str, mode: int = 0o755) -> None: return os.mkdir(fsenc(p), mode) +def open(p: str, *a, **ka) -> Any: + return os.open(fsenc(p), *a, **ka) + + def rename(src: str, dst: str) -> None: return os.rename(fsenc(src), fsenc(dst)) diff --git a/copyparty/dxml.py b/copyparty/dxml.py index 5452bbd4..31f0dd4d 100644 --- a/copyparty/dxml.py +++ b/copyparty/dxml.py @@ -1,10 +1,9 @@ -import sys import importlib +import sys import xml.etree.ElementTree as ET from .__init__ import PY2 - try: from typing import Any, Optional except: diff --git a/copyparty/ftpd.py b/copyparty/ftpd.py index 942edd87..cbbba965 100644 --- a/copyparty/ftpd.py +++ b/copyparty/ftpd.py @@ -175,7 +175,10 @@ class FtpFs(AbstractedFS): vfs, rem = self.hub.asrv.vfs.get(vpath, self.uname, True, False) fsroot, vfs_ls1, vfs_virt = vfs.ls( - rem, self.uname, not self.args.no_scandir, [[True], [False, True]] + rem, + self.uname, + not self.args.no_scandir, + [[True, False], [False, True]], ) vfs_ls = [x[0] for x in vfs_ls1] vfs_ls.extend(vfs_virt.keys()) diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index 0b1bd08f..5a8f3ba9 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -754,7 +754,7 @@ class HttpCli(object): elif depth == "1": _, vfs_ls, vfs_virt = vn.ls( - rem, self.uname, not self.args.no_scandir, [[True]] + rem, self.uname, not self.args.no_scandir, [[True, False]] ) zi = int(time.time()) zsr = os.stat_result((16877, -1, -1, 1, 1000, 1000, 8, zi, zi, zi)) @@ -844,8 +844,8 @@ class HttpCli(object): self.log("{} tried to proppatch [{}]".format(self.uname, self.vpath)) raise Pebkac(401, "authenticate") - from .dxml import parse_xml, mkenod, mktnod from xml.etree import ElementTree as ET + from .dxml import mkenod, mktnod, parse_xml vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False) # abspath = vn.dcanonical(rem) @@ -901,8 +901,8 @@ class HttpCli(object): self.log("{} tried to lock [{}]".format(self.uname, self.vpath)) raise Pebkac(401, "authenticate") - from .dxml import parse_xml, mkenod, mktnod from xml.etree import ElementTree as ET + from .dxml import mkenod, mktnod, parse_xml vn, rem = self.asrv.vfs.get(self.vpath, self.uname, False, False) abspath = vn.dcanonical(rem) @@ -2694,7 +2694,10 @@ class HttpCli(object): try: vn, rem = self.asrv.vfs.get(top, self.uname, True, False) fsroot, vfs_ls, vfs_virt = vn.ls( - rem, self.uname, not self.args.no_scandir, [[True], [False, True]] + rem, + self.uname, + not self.args.no_scandir, + [[True, False], [False, True]], ) except: vfs_ls = [] @@ -3103,7 +3106,7 @@ class HttpCli(object): return self.tx_zip(k, v, vn, rem, [], self.args.ed) fsroot, vfs_ls, vfs_virt = vn.ls( - rem, self.uname, not self.args.no_scandir, [[True], [False, True]] + rem, self.uname, not self.args.no_scandir, [[True, False], [False, True]] ) stats = {k: v for k, v in vfs_ls} ls_names = [x[0] for x in vfs_ls] diff --git a/copyparty/httpsrv.py b/copyparty/httpsrv.py index 7d22ba78..26b26f2b 100644 --- a/copyparty/httpsrv.py +++ b/copyparty/httpsrv.py @@ -32,11 +32,11 @@ from .__init__ import MACOS, TYPE_CHECKING, EnvParams from .bos import bos from .httpconn import HttpConn from .util import ( + E_SCK, FHC, Daemon, Garda, Magician, - E_SCK, min_ex, shut_socket, spack, diff --git a/copyparty/smbd.py b/copyparty/smbd.py new file mode 100644 index 00000000..3fae053c --- /dev/null +++ b/copyparty/smbd.py @@ -0,0 +1,215 @@ +# coding: utf-8 +from __future__ import print_function, unicode_literals + +import inspect +import logging +import os +import random +import stat +import sys +import time +from types import SimpleNamespace + +from .__init__ import ANYWIN, TYPE_CHECKING +from .authsrv import LEELOO_DALLAS, VFS +from .util import Daemon, min_ex +from .bos import bos + +try: + from typing import Any +except: + pass + +if TYPE_CHECKING: + from .svchub import SvcHub + + +class Standin(object): + pass + + +class HLog(logging.Handler): + def __init__(self, log_func: Any) -> None: + logging.Handler.__init__(self) + self.log_func = log_func + + def __repr__(self) -> str: + level = logging.getLevelName(self.level) + return "<%s cpp(%s)>" % (self.__class__.__name__, level) + + def flush(self) -> None: + pass + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + self.log_func("smb", msg) + + +class SMB(object): + def __init__(self, hub: "SvcHub") -> None: + self.hub = hub + self.args = hub.args + self.asrv = hub.asrv + self.log_func = hub.log + + handler = HLog(hub.log) + lvl = logging.DEBUG if self.args.smb_dbg else logging.INFO + logging.getLogger().addHandler(handler) + logging.getLogger().setLevel(lvl) + + try: + from impacket import smbserver + from impacket.ntlm import compute_lmhash, compute_nthash + except ImportError: + m = "\033[36m\n{}\033[31m\n\nERROR: need 'impacket'; please run this command:\033[33m\n {} -m pip install --user impacket\n\033[0m" + print(m.format(min_ex(), sys.executable)) + sys.exit(1) + + # patch vfs into smbserver.os + fos = SimpleNamespace() + for k in os.__dict__: + try: + setattr(fos, k, getattr(os, k)) + except: + pass + fos.listdir = self._listdir + fos.open = self._open + fos.stat = self._stat + smbserver.os = fos + + # ...and smbserver.os.path + fop = SimpleNamespace() + for k in os.path.__dict__: + try: + setattr(fop, k, getattr(os.path, k)) + except: + pass + fop.exists = self._p_exists + fop.getsize = self._p_getsize + fop.isdir = self._p_isdir + smbserver.os.path = fop + + # other patches + smbserver.isInFileJail = self._is_in_file_jail + self._disarm() + + ip = self.args.i[0] + port = int(self.args.smb_port) + srv = smbserver.SimpleSMBServer(listenAddress=ip, listenPort=port) + + ro = "no" if self.args.smbw else "yes" # (does nothing) + srv.addShare("A", "/", readOnly=ro) + srv.setSMB2Support(not self.args.smb1) + + for name, pwd in self.asrv.acct.items(): + for u, p in ((name, pwd), (pwd, "")): + lmhash = compute_lmhash(p) + nthash = compute_nthash(p) + srv.addCredential(u, 0, lmhash, nthash) + + chi = [random.randint(0, 255) for x in range(8)] + cha = "".join(["{:02x}".format(x) for x in chi]) + srv.setSMBChallenge(cha) + + self.srv = srv + self.stop = srv.stop + logging.info("listening @ %s:%s", ip, port) + + def start(self) -> None: + Daemon(self.srv.start) + + def _v2a(self, caller: str, vpath: str, *a: Any) -> tuple[VFS, str]: + vpath = vpath.replace("\\", "/").lstrip("/") + # cf = inspect.currentframe().f_back + # c1 = cf.f_back.f_code.co_name + # c2 = cf.f_code.co_name + logging.debug('%s("%s", %s)\033[K\033[0m', caller, vpath, str(a)) + + # TODO find a way to grab `identity` in smbComSessionSetupAndX and smb2SessionSetup + vfs, rem = self.asrv.vfs.get(vpath, LEELOO_DALLAS, True, True) + return vfs, vfs.canonical(rem) + + def _listdir(self, vpath: str, *a: Any, **ka: Any) -> list[str]: + vpath = vpath.replace("\\", "/").lstrip("/") + # caller = inspect.currentframe().f_back.f_code.co_name + logging.info('listdir("%s", %s)\033[K\033[0m', vpath, str(a)) + vfs, rem = self.asrv.vfs.get(vpath, LEELOO_DALLAS, False, False) + _, vfs_ls, vfs_virt = vfs.ls( + rem, LEELOO_DALLAS, not self.args.no_scandir, [[False, False]] + ) + ls = [x[0] for x in vfs_ls] + ls.extend(vfs_virt.keys()) + return ls + + def _open( + self, vpath: str, flags: int, chmod: int = 0o777, *a: Any, **ka: Any + ) -> Any: + if not self.args.smbw: + ok = os.O_RDONLY + if ANYWIN: + ok |= os.O_BINARY + + if flags != ok: + logging.info("blocked write to %s", vpath) + raise Exception("read-only") + + return bos.open(self._v2a("open", vpath, *a)[1], flags, chmod, *a, **ka) + + def _stat(self, vpath: str, *a: Any, **ka: Any) -> os.stat_result: + return bos.stat(self._v2a("stat", vpath, *a)[1], *a, **ka) + + def _p_exists(self, vpath: str) -> bool: + try: + bos.stat(self._v2a("p.exists", vpath)[1]) + return True + except: + return False + + def _p_getsize(self, vpath: str) -> int: + st = bos.stat(self._v2a("p.getsize", vpath)[1]) + return st.st_size + + def _p_isdir(self, vpath: str) -> bool: + try: + st = bos.stat(self._v2a("p.isdir", vpath)[1]) + return stat.S_ISDIR(st.st_mode) + except: + return False + + def _hook(self, *a: Any, **ka: Any) -> None: + src = inspect.currentframe().f_back.f_code.co_name + logging.error("\033[31m%s:hook(%s)\033[0m", src, a) + raise Exception("nope") + + def _disarm(self) -> None: + from impacket import smbserver + + smbserver.os.chmod = self._hook + smbserver.os.chown = self._hook + smbserver.os.ftruncate = self._hook + smbserver.os.lchown = self._hook + smbserver.os.link = self._hook + smbserver.os.lstat = self._hook + smbserver.os.mkdir = self._hook + smbserver.os.remove = self._hook + smbserver.os.rename = self._hook + smbserver.os.replace = self._hook + smbserver.os.scandir = self._hook + smbserver.os.symlink = self._hook + smbserver.os.truncate = self._hook + smbserver.os.unlink = self._hook + smbserver.os.walk = self._hook + + smbserver.os.path.abspath = self._hook + smbserver.os.path.expanduser = self._hook + smbserver.os.path.getatime = self._hook + smbserver.os.path.getctime = self._hook + smbserver.os.path.getmtime = self._hook + smbserver.os.path.isabs = self._hook + smbserver.os.path.isfile = self._hook + smbserver.os.path.islink = self._hook + smbserver.os.path.realpath = self._hook + + def _is_in_file_jail(self, *a: Any) -> bool: + # handled by vfs + return True diff --git a/copyparty/svchub.py b/copyparty/svchub.py index 3bc89227..a122da27 100644 --- a/copyparty/svchub.py +++ b/copyparty/svchub.py @@ -186,6 +186,17 @@ class SvcHub(object): self.ftpd = Ftpd(self) + if args.smb: + # impacket.dcerpc is noisy about listen timeouts + sto = socket.getdefaulttimeout() + socket.setdefaulttimeout(None) + + from .smbd import SMB + + self.smbd = SMB(self) + socket.setdefaulttimeout(sto) + self.smbd.start() + # decide which worker impl to use if self.check_mp_enable(): from .broker_mp import BrokerMp as Broker @@ -342,6 +353,17 @@ class SvcHub(object): self.shutdown() + def kill9(self, delay: float = 0.0): + if delay > 0.01: + time.sleep(delay) + print("component stuck; performing sigkill") + time.sleep(0.1) + + if ANYWIN: + os.system("taskkill /f /pid {}".format(os.getpid())) + else: + os.kill(os.getpid(), signal.SIGKILL) + def signal_handler(self, sig: int, frame: Optional[FrameType]) -> None: if self.stopping: if self.nsigs <= 0: @@ -351,10 +373,7 @@ class SvcHub(object): except: pass - if ANYWIN: - os.system("taskkill /f /pid {}".format(os.getpid())) - else: - os.kill(os.getpid(), signal.SIGKILL) + self.kill9() else: self.nsigs -= 1 return @@ -395,6 +414,10 @@ class SvcHub(object): if n == 3: self.pr("waiting for thumbsrv (10sec)...") + if hasattr(self, "smbd"): + Daemon(self.kill9, a=(1,)) + self.smbd.stop() + self.pr("nailed it", end="") ret = self.retcode except: diff --git a/copyparty/util.py b/copyparty/util.py index 1568f90d..16dcda7f 100644 --- a/copyparty/util.py +++ b/copyparty/util.py @@ -185,6 +185,9 @@ IMPLICATIONS = [ ["e2vp", "e2v"], ["e2v", "e2d"], ["daw", "dav"], + ["smbw", "smb"], + ["smb1", "smb"], + ["smb_dbg", "smb"], ] @@ -1373,7 +1376,7 @@ def gen_filekey_dbg( try: import inspect - ctx = ",".join(inspect.stack()[n][3] for n in range(2, 5)) + ctx = ",".join(inspect.stack()[n].function for n in range(2, 5)) except: ctx = "" diff --git a/scripts/sfx.ls b/scripts/sfx.ls index 5519b07e..aacaa4d3 100644 --- a/scripts/sfx.ls +++ b/scripts/sfx.ls @@ -22,6 +22,7 @@ copyparty/mtag.py, copyparty/res, copyparty/res/COPYING.txt, copyparty/res/insecure.pem, +copyparty/smbd.py, copyparty/star.py, copyparty/stolen, copyparty/stolen/__init__.py, diff --git a/scripts/strip_hints/a.py b/scripts/strip_hints/a.py index 018cc5a1..4901bd0f 100644 --- a/scripts/strip_hints/a.py +++ b/scripts/strip_hints/a.py @@ -58,7 +58,7 @@ def uh1(fp): lns = [] for ln in cs.split("\n"): m = ptn.match(ln) - if m: + if m and "SimpleNamespace" not in ln: ln = m.group(1) + "raise Exception()" lns.append(ln) diff --git a/setup.py b/setup.py index a9f4e63a..5455f7aa 100755 --- a/setup.py +++ b/setup.py @@ -112,7 +112,10 @@ args = { "Programming Language :: Python :: Implementation :: PyPy", "Environment :: Console", "Environment :: No Input/Output (Daemon)", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: System Administrators", "Topic :: Communications :: File Sharing", + "Topic :: Internet :: File Transfer Protocol (FTP)", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", ], "include_package_data": True, @@ -125,6 +128,7 @@ args = { "audiotags": ["mutagen"], "ftpd": ["pyftpdlib"], "ftps": ["pyftpdlib", "pyopenssl"], + "smbd": ["impacket"], }, "entry_points": {"console_scripts": ["copyparty = copyparty.__main__:main"]}, "scripts": ["bin/copyparty-fuse.py", "bin/up2k.py"],