Ctrl K
ComunidadGuías…

simple_nas/app/ftp.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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); /<share>/... 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/<share> (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 <share>/.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