#!/usr/bin/env python3 """FTP: the Samba shares for devices that only speak FTP (IP cameras, scanners, printers). - Served by pyftpdlib on its own port, off by default. Plain FTP without TLS - meant for the home network only, never forward the port to the internet. - The root / lists the shares the logged-in user may open and that have FTP ticked in the share dialog (services.py); //... is the share itself. Same rules as in Samba: valid users and groups, read-only shares stay read-only. - Login with the Samba users and passwords (NT hash, see webdav.py). Public shares need a login too, anonymous FTP does not exist here. Failed logins count in the same limiter as WebDAV. - Deleting moves into the share's .recycle folder when the recycle bin is on, like Samba does. - Symlinks pointing outside the share are refused, as in WebDAV. """ import logging import os import random import shutil import threading import time from pyftpdlib.authorizers import AuthenticationFailed from pyftpdlib.filesystems import AbstractedFS from pyftpdlib.handlers import FTPHandler from pyftpdlib.log import config_logging from pyftpdlib.servers import ThreadedFTPServer from ratelimit import AUTHFAIL_IP, AUTHFAIL_USER, LIMITER, LOCK_BASE, LOCK_CAP from services import offers from webdav import RECYCLE_EXCLUDE, _bin_target, mounted_points, share_allows, share_available ROOT_DIR = "/run/simple-nas-ftp" # empty stand-in for "/", the list of shares READ_PERMS = "elr" # cd, list, download WRITE_PERMS = "adfmwMT" # append, delete, mkdir/rmdir, rename, upload, chmod, mtime DEFAULT_PASSIVE = "30000-30010" def parse_passive_ports(text): """'30000-30010' -> range; empty or broken -> the default range.""" try: low, high = (int(p) for p in str(text or DEFAULT_PASSIVE).split("-", 1)) if 1024 <= low <= high <= 65535: return list(range(low, high + 1)) except ValueError: pass low, high = (int(p) for p in DEFAULT_PASSIVE.split("-")) return list(range(low, high + 1)) class ShareSet: """The active shares, reread when shares.json changes or refresh() is called (mount, unmount).""" def __init__(self, shares_file, load_shares, load_groups): self._shares_file = shares_file self._load_shares = load_shares self.load_groups = load_groups self._lock = threading.Lock() self._key = None self._active = {} self._generation = 0 def refresh(self): with self._lock: self._generation += 1 def active(self): try: mtime = os.stat(self._shares_file).st_mtime_ns except OSError: mtime = 0 key = (mtime, self._generation) with self._lock: if key != self._key: mounts = mounted_points() self._active = {s["name"]: s for s in self._load_shares() if s.get("name") and "/" not in s["name"] and offers(s, "ftp") and share_available(s.get("path", ""), mounts)} self._key = key return dict(self._active) def names(self): return sorted(self.active()) def for_user(self, user): groups = self.load_groups() return {n: s for n, s in self.active().items() if share_allows(s, user, groups)} class SambaAuthorizer: """pyftpdlib authorizer: Samba passwords, share rules from shares.json, lockouts as in WebDAV.""" def __init__(self, shares, passwords, limiter=LIMITER, log=lambda *a, **k: None, recycle_on=lambda: False, recycle_dir=".recycle"): self.shares = shares self.passwords = passwords self.limiter = limiter self.log = log self.recycle_on = recycle_on self.recycle_dir = recycle_dir def validate_authentication(self, username, password, handler): ip = getattr(handler, "remote_ip", "?") ip_key, user_key = f"authfail:ip:{ip}", f"authfail:user:{(username or '').lower()}" if self.limiter.locked(ip_key, *AUTHFAIL_IP) or (username and self.limiter.locked(user_key, *AUTHFAIL_USER)): self.log("rate_limited", ip=ip, user=username, detail="ftp locked") raise AuthenticationFailed("Too many failed logins, try again later.") if username and username != "anonymous" and self.passwords.check(username, password or ""): return time.sleep(random.uniform(0.15, 0.35)) for key, (limit, window) in ((ip_key, AUTHFAIL_IP), (user_key, AUTHFAIL_USER)): if key == user_key and not username: continue self.limiter.record(key) if self.limiter.count(key, window) >= limit: dur = self.limiter.lock(key, LOCK_BASE, LOCK_CAP) self.log("rate_limited", ip=ip, detail=f"ftp lock {key.split(':', 1)[1]} {dur // 60} min") self.log("auth_fail", ip=ip, user=username, detail="ftp") raise AuthenticationFailed("Authentication failed.") def get_home_dir(self, username): return ROOT_DIR def has_user(self, username): return True def get_perms(self, username): return READ_PERMS + WRITE_PERMS # the upper bound; has_perm decides per path def has_perm(self, username, perm, path=None): if path is None: return perm in READ_PERMS + WRITE_PERMS found = shares_of(path, self.shares.for_user(username)) if not found: # the root listing: look only return perm in READ_PERMS if perm in READ_PERMS: return True path = os.path.normpath(path) if perm in "df" and any(path == os.path.normpath(s["path"]) for s in found): return False # the share folder itself stays # nested shares: writable if one of them is - over Samba it would be the same return any(s.get("writable", True) for s in found) def get_msg_login(self, username): return "Simple NAS - welcome." def get_msg_quit(self, username): return "Goodbye." def impersonate_user(self, username, password): pass # runs as root like Samba (force user = root) def terminate_impersonation(self, username): pass def _inside(path, root): root = os.path.normpath(root) return path == root or path.startswith(root.rstrip("/") + "/") def shares_of(path, shares): """All shares a real path lies in (shares may be nested, e.g. /media and /media/camera).""" path = os.path.normpath(path) return [s for s in shares.values() if _inside(path, s["path"])] def share_of(path, shares, prefer=None): """The share to show a real path in: `prefer` (the share the client is browsing), else the deepest.""" found = shares_of(path, shares) for share in found: if share["name"] == prefer: return share return max(found, key=lambda s: len(os.path.normpath(s["path"])), default=None) class SharesFS(AbstractedFS): """Virtual root with one folder per share; below that the real share folders.""" def _shares(self): return self.cmd_channel.authorizer.shares.for_user(self.cmd_channel.username) def _share_name(self): return self.cwd.strip("/").split("/", 1)[0] or None def ftp2fs(self, ftppath): parts = self.ftpnorm(ftppath).strip("/").split("/", 1) if not parts[0]: return ROOT_DIR share = self._shares().get(parts[0]) if share is None: return os.path.join(ROOT_DIR, parts[0]) # does not exist: "No such file" return os.path.normpath(os.path.join(share["path"], parts[1] if len(parts) > 1 else "")) def fs2ftp(self, fspath): path = os.path.normpath(fspath) if path == ROOT_DIR: return "/" if os.path.dirname(path) == ROOT_DIR: return "/" + os.path.basename(path) share = share_of(path, self._shares(), prefer=self._share_name()) if share is None: return "/" rel = os.path.relpath(path, os.path.normpath(share["path"])) return "/" + share["name"] + ("" if rel == "." else "/" + rel) def validpath(self, path): path = os.path.normpath(path) if path == ROOT_DIR or os.path.dirname(path) == ROOT_DIR: return True real = os.path.realpath(path) for share in self._shares().values(): root = os.path.realpath(share["path"]) if real == root or real.startswith(root.rstrip("/") + "/"): return True return False def _real(self, path): """ROOT_DIR/ (what a listing of / joins together) -> the share folder.""" path = os.path.normpath(path) if os.path.dirname(path) == ROOT_DIR: share = self._shares().get(os.path.basename(path)) if share is not None: return share["path"] return path def chdir(self, path): # no os.chdir: the process is shared with the web interface and other FTP sessions real = self._real(path) if not os.path.isdir(real): raise OSError(2, "No such directory") if not os.access(real, os.X_OK): raise OSError(13, "Permission denied") self.cwd = self.fs2ftp(path) def listdir(self, path): if os.path.normpath(path) == ROOT_DIR: return sorted(self._shares()) return os.listdir(self._real(path)) listdirinfo = listdir def stat(self, path): return os.stat(self._real(path)) def lstat(self, path): return os.lstat(self._real(path)) def isdir(self, path): return os.path.isdir(self._real(path)) def isfile(self, path): return os.path.isfile(self._real(path)) def islink(self, path): return os.path.islink(self._real(path)) def lexists(self, path): return os.path.lexists(self._real(path)) def getsize(self, path): return os.path.getsize(self._real(path)) def getmtime(self, path): return os.path.getmtime(self._real(path)) def remove(self, path): if not self._recycle(path): os.remove(path) def rmdir(self, path): os.rmdir(path) # only empty folders; their files went to the bin already def _recycle(self, path): """Move a deleted file into /.recycle when the bin is on; False = delete for good.""" auth = self.cmd_channel.authorizer if not auth.recycle_on(): return False share = share_of(path, self._shares(), prefer=self._share_name()) if share is None or share.get("timemachine"): return False root = os.path.normpath(share["path"]) rel = os.path.relpath(path, root) name = os.path.basename(path) if rel.split(os.sep, 1)[0] == auth.recycle_dir or name.lower().endswith(RECYCLE_EXCLUDE) \ or name.startswith("~$"): return False bin_root = os.path.join(root, auth.recycle_dir) if os.path.islink(bin_root): return False target = _bin_target(bin_root, rel) os.makedirs(os.path.dirname(target), mode=0o777, exist_ok=True) shutil.move(path, target) now = time.time() try: os.utime(target, (now, now)) # the purge counts the days from here except OSError: pass return True class _Handler(FTPHandler): abstracted_fs = SharesFS banner = "Simple NAS FTP ready." permit_foreign_addresses = False permit_privileged_ports = False timeout = 300 use_sendfile = False def make_handler(authorizer, passive_ports=None, masquerade_address=None): handler = type("SimpleNasFTPHandler", (_Handler,), {}) handler.authorizer = authorizer handler.passive_ports = passive_ports or parse_passive_ports(None) if masquerade_address: handler.masquerade_address = masquerade_address return handler def serve(handler, port, host="0.0.0.0"): """ThreadedFTPServer on its own port, in a thread. Returns the server or raises OSError.""" os.makedirs(ROOT_DIR, exist_ok=True) os.chmod(ROOT_DIR, 0o555) # pyftpdlib logs every connection and transfer; a camera would flood the add-on log. # Failed logins and lockouts are logged by SambaAuthorizer. Without a handler of its own pyftpdlib # would set up logging at INFO when the loop starts. if logging.getLogger("pyftpdlib").handlers: logging.getLogger("pyftpdlib").setLevel(logging.WARNING) else: config_logging(level=logging.WARNING) server = ThreadedFTPServer((host, port), handler) server.max_cons = 32 server.max_cons_per_ip = 8 threading.Thread(target=server.serve_forever, kwargs={"handle_exit": False}, daemon=True, name="ftp").start() return server