Ctrl K
ComunidadGuías…

simple_nas/app/generate_smb_conf.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Generate /etc/samba/smb.conf from shares.json and system config."""
import errno, json, os, re, sys

from services import offers


def get_mounted_points():
    """Return set of all mount points listed in /proc/mounts."""
    mounted = set()
    try:
        with open("/proc/mounts") as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2:
                    mounted.add(parts[1])
    except Exception:
        pass
    return mounted


def _path_under_mount(path, mounted_points):
    """Return the closest ancestor of `path` that is an actual mount point,
    or None if no ancestor is mounted (excluding root '/')."""
    check = os.path.realpath(path)
    while check not in ("/", ""):
        if check in mounted_points:
            return check
        parent = os.path.dirname(check)
        if parent == check:
            break
        check = parent
    return None


def share_available(path, mounted_points):
    """Return True if the share path is currently accessible.

    Paths under /media/ or /mnt/ require an actual mount so that an
    unmounted / missing USB device never silently maps to an empty host
    directory.

    If the share path itself does not exist yet but its parent IS an actual
    mount point (e.g. share = /mnt/nas/backup and /mnt/nas is the mounted
    drive), auto-create the subdirectory so the share works on first start.
    This handles the case where the share folder was originally created on
    the container overlay before the drive was mounted, then got hidden by
    the live filesystem after the mount.
    """
    needs_real_mount = path.startswith("/media/") or path.startswith("/mnt/")

    if needs_real_mount:
        ancestor_mount = _path_under_mount(path, mounted_points)
        if ancestor_mount is None:
            return False  # nothing mounted under this path
        # Path is below a real mount point — auto-create missing subdirs
        if not os.path.exists(path):
            try:
                os.makedirs(path, mode=0o2775, exist_ok=True)
                print(f"share_available: auto-created missing share dir {path}")
            except OSError as e:
                print(f"share_available: cannot create {path}: {e}")
                return False
        return True

    if not os.path.exists(path):
        return False
    return True  # HA-mapped or other always-available path

DATA_DIR    = os.environ.get("SIMPLE_NAS_DATA", "/data")              # Tests: Temp-Ordner
SHARES_FILE = f"{DATA_DIR}/shares.json"
USERS_FILE  = f"{DATA_DIR}/users.json"
GROUPS_FILE = f"{DATA_DIR}/groups.json"
RECYCLE_FILE = f"{DATA_DIR}/recycle.json"
SMB_CONF    = os.environ.get("SIMPLE_NAS_SMB_CONF", "/etc/samba/smb.conf")

# Network recycle bin (Settings tab): what a client deletes over SMB is moved
# to <share>/.recycle instead; app.py empties it after the configured days.
# Within one share only - a drive mounted below the share root cannot rename
# into the bin, and vfs_recycle then deletes for real.
RECYCLE_OPTIONS = """   recycle:repository = .recycle
   recycle:keeptree = yes
   recycle:versions = yes
   recycle:directory_mode = 0777
   recycle:exclude = *.tmp *.temp ~$* .~lock.*# *.part *.crdownload
   recycle:exclude_dir = .recycle
"""


MAC_MODULES = "catia fruit streams_xattr"
MAC_VETO = "/.DS_Store/.TemporaryItems/.Trashes/.fseventsd/.Spotlight-V100/"
# Named streams of shares on exFAT/FAT (issue #15). Inside the container /tmp starts empty with
# every add-on start - see the comment where it is used.
NO_XATTR_STREAMS = os.environ.get("SIMPLE_NAS_STREAMS_DIR", "/tmp/simple-nas-streams")


def streams_dir(share_name):
    return os.path.join(NO_XATTR_STREAMS, re.sub(r"[^A-Za-z0-9_.-]", "_", share_name) or "share")


def supports_xattr(path):
    """Can the drive behind `path` store extended attributes? exFAT and FAT cannot (issue #15).

    streams_xattr keeps the Finder metadata and resource forks of macOS in them. Without that
    support every stream write fails, and since ._* AppleDouble files are vetoed as well, macOS
    has nowhere to put the metadata and aborts the copy with error 100093.
    """
    test_paths = os.environ.get("SIMPLE_NAS_NO_XATTR_PATHS")  # Tests: tmpfs/ext4 can always
    if test_paths is not None:
        return os.path.realpath(path) not in {os.path.realpath(p) for p in test_paths.split(":") if p}
    probe = "user.simplenas_probe"
    try:
        os.setxattr(path, probe, b"1")
        os.removexattr(path, probe)
    except OSError as e:
        return e.errno not in (errno.ENOTSUP, errno.EOPNOTSUPP)  # e.g. read-only: nothing to write
    return True


def recycle_enabled():
    try:
        with open(RECYCLE_FILE) as f:
            saved = json.load(f)
    except Exception:
        return True   # on unless switched off in the Settings tab
    return not isinstance(saved, dict) or bool(saved.get("enabled", True))

workgroup = sys.argv[1] if len(sys.argv) > 1 else "WORKGROUP"
nas_name  = sys.argv[2] if len(sys.argv) > 2 else "SimpleNAS"
smb_port  = int(sys.argv[3]) if len(sys.argv) > 3 else 445

shares, users, groups = [], [], []
try:
    with open(SHARES_FILE) as f: shares = json.load(f)
except Exception: pass
try:
    with open(USERS_FILE) as f: users = json.load(f)
except Exception: pass
try:
    with open(GROUPS_FILE) as f: groups = json.load(f)
except Exception: pass

conf = f"""[global]
   workgroup = {workgroup}
   netbios name = {nas_name}
   server string = {nas_name}
   server role = standalone server
   log file = /var/log/samba/%m.log
   max log size = 1000
   server signing = auto
   server min protocol = SMB2
   server max protocol = SMB3
   obey pam restrictions = no
   unix password sync = no
   passwd program = /usr/bin/passwd %u
   pam password change = no
   map to guest = Bad User
   guest account = nobody
   usershare allow guests = yes
   dns proxy = no
   ntlm auth = yes
   load printers = no
   printing = bsd
   printcap name = /dev/null
   disable spoolss = yes
   log level = 2
   # Network browser visibility
   local master = yes
   preferred master = yes
   os level = 65
   wins support = yes
   # Custom SMB port (default 445; change if official Samba add-on is also running)
   smb ports = {smb_port}
   # macOS Finder / iOS Files app compatibility (resource forks, metadata,
   # correct filename display). Harmless for Windows/Linux clients. (Issue #7)
   vfs objects = {MAC_MODULES}
   fruit:metadata = stream
   fruit:model = MacSamba
   fruit:posix_rename = yes
   fruit:veto_appledouble = no
   fruit:nfs_aces = no
   fruit:wipe_intentionally_left_blank_rfork = yes
   fruit:delete_empty_adfiles = yes
   # Suppress macOS junk files on all shares
   veto files = /._*{MAC_VETO}
   delete veto files = yes

"""

mounted_points = get_mounted_points()
inactive_count = 0
use_recycle = recycle_enabled()
smb_off = [s for s in shares if not offers(s, "smb")]
shares = [s for s in shares if offers(s, "smb")]
for share in smb_off:
    # only for WebDAV/FTP (share dialog): not in the Windows network at all
    conf += f"# [{share.get('name', 'share')}] - SMB switched off for this share\n\n"

for share in shares:
    name         = share.get("name", "share")
    path         = share.get("path", "/data/shares")
    public       = share.get("public", False)
    writable     = share.get("writable", True)
    comment      = share.get("comment", "")
    valid_users  = share.get("users", [])
    valid_groups = share.get("groups", [])
    timemachine  = bool(share.get("timemachine")) and writable

    available = share_available(path, mounted_points)

    if available:
        os.makedirs(path, mode=0o2775, exist_ok=True)
        try:
            os.chmod(path, 0o2775)
        except Exception:
            pass

    conf += f"[{name}]\n"
    conf += f"   comment = {comment or name}\n"
    conf += f"   path = {path}\n"

    if not available:
        # Path not mounted / does not exist — disable share so Samba
        # does NOT expose an empty directory or the wrong device.
        conf += f"   available = no\n"
        conf += f"   # WARNING: '{path}' is not mounted or does not exist\n"
        inactive_count += 1
        conf += "\n"
        continue

    conf += f"   browseable = yes\n"
    conf += f"   read only = {'no' if writable else 'yes'}\n"
    conf += f"   guest ok = {'yes' if public else 'no'}\n"

    # Build valid users list: individual users + @group notation
    # IMPORTANT: don't set valid users when share is public — it overrides guest ok!
    if not public:
        vu_parts = list(valid_users)
        for gn in valid_groups:
            vu_parts.append(f"@{gn}")
        if vu_parts:
            conf += f"   valid users = {' '.join(vu_parts)}\n"

    conf += f"   create mask = 0666\n"
    conf += f"   directory mask = 0777\n"
    conf += f"   force create mode = 0666\n"
    conf += f"   force directory mode = 0777\n"
    conf += f"   force user = root\n"
    conf += f"   force group = root\n"
    if timemachine:
        # macOS Time Machine target (issue #11). The global fruit/streams_xattr modules do the work;
        # the drive must support extended attributes (ext4, NTFS - not exFAT/FAT).
        # No recycle bin here: Time Machine prunes old backup bands all the time, and every one of
        # them would end up in .recycle and fill the drive.
        conf += "   fruit:time machine = yes\n"
        max_gb = share.get("timemachine_max_gb") or 0
        if isinstance(max_gb, int) and max_gb > 0:
            conf += f"   fruit:time machine max size = {max_gb}G\n"
    else:
        modules = MAC_MODULES
        if not supports_xattr(path):
            # exFAT/FAT (issue #15) keep no extended attributes, so streams_xattr cannot work.
            # Without named streams the iOS Files app opens every file read-only, and macOS gives
            # up with error 100093. streams_depot keeps the streams as files instead. Its folders
            # are named after the inode number, which exFAT/FAT hand out anew whenever a file is
            # loaded (iunique) - a lasting depot would show old metadata on the wrong file after
            # a reboot. So the depot lives in /tmp, which starts empty with every add-on start:
            # Finder metadata does not survive a restart there, but never lands on another file.
            # Resource forks go to ._* files next to the file (fruit:resource = file, the
            # default), which exFAT keeps by name - so ._* must not be vetoed here.
            modules = "catia fruit streams_depot"
            depot = streams_dir(name)
            os.makedirs(depot, exist_ok=True)
            conf += "   ea support = no\n"
            conf += f"   streams_depot:directory = {depot}\n"
            # the validity check needs an extended attribute on every file
            conf += "   streams_depot:check_valid = no\n"
            conf += "   streams_depot:delete_lost = yes\n"
            conf += f"   veto files = {MAC_VETO}\n"
        if use_recycle and writable:
            modules += " recycle"
            conf += RECYCLE_OPTIONS
        if modules != MAC_MODULES:
            # replaces the global vfs objects for this share
            conf += f"   vfs objects = {modules}\n"
    conf += "\n"

os.makedirs(os.path.dirname(SMB_CONF), exist_ok=True)
with open(SMB_CONF, "w") as f:
    f.write(conf)

active = len(shares) - inactive_count
print(f"Generated {SMB_CONF} with {len(shares)} share(s) "
      f"({active} active, {inactive_count} inactive/not mounted"
      f"{f', {len(smb_off)} without SMB' if smb_off else ''}).")