add SMB/CIFS server

This commit is contained in:
ed 2022-10-23 23:08:00 +02:00
parent 4bcd30da6b
commit f3a501db30
14 changed files with 325 additions and 20 deletions

View file

@ -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`

View file

@ -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

View file

@ -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,

View file

@ -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))

View file

@ -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:

View file

@ -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())

View file

@ -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]

View file

@ -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,

215
copyparty/smbd.py Normal file
View file

@ -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

View file

@ -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:

View file

@ -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 = ""

View file

@ -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,

View file

@ -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)

View file

@ -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"],