"""Per-customer SFTP account + web-root ownership. One chrooted SFTP user per customer (``sftp_``), chrooted to the customer directory so they see each of their sites' web roots as sub-folders. Ownership scheme (see site-templates/README.md "provisioning note"): - The customer dir (chroot target) stays root-owned 755 — an sshd requirement. - Each site's web dir is chowned to the fpm uid/gid and set 2775 (setgid) so files created there stay group-owned and group-writable. - The SFTP user's primary group is the fpm group, so php-fpm and SFTP share group access. SFTP writes with umask 0002 (set via ForceCommand) to keep new files group-writable. (php-fpm umask hardening is a documented follow-up.) """ from __future__ import annotations from . import context, naming from .config import Config, Site from .runner import Runner SSHD_DROPIN = "/etc/ssh/sshd_config.d/sftp-{customer}.conf" def _user_exists(runner: Runner, user: str) -> bool: if runner.dry_run: return False out = runner.run(["id", "-u", user], check=False, capture=True) return out.strip().isdigit() def set_web_ownership(runner: Runner, cfg: Config, site: Site) -> None: web = context.webroot(cfg, site) runner.run(["chown", "-R", f"{cfg.fpm_uid}:{cfg.fpm_gid}", web]) runner.run(["chmod", "2775", web]) def ensure_customer_account(runner: Runner, cfg: Config, customer: str, password: str) -> bool: """Ensure the customer's SFTP account + chroot config exist. Idempotent: the password is set only when the account is first created (so provisioning a second site for an existing customer doesn't reset their password). Returns True if the account was newly created. """ user = naming.sftp_user(customer) home = f"{cfg.customers_root}/{customer}" created = not _user_exists(runner, user) if created: runner.run([ "useradd", "-M", "-N", "-g", str(cfg.fpm_gid), # primary group = fpm group "-G", "sftponly", # supplementary: enables the Match block "-s", "/usr/sbin/nologin", "-d", home, user, ]) runner.run(["chpasswd"], input=f"{user}:{password}\n", secret=True) dropin = SSHD_DROPIN.format(customer=customer) content = ( f"Match User {user}\n" f" ChrootDirectory {home}\n" f" ForceCommand internal-sftp -u 0002\n" ) runner.write_file(dropin, content, mode=0o644) runner.run(["systemctl", "reload", "ssh"]) return created def remove_customer_account(runner: Runner, cfg: Config, customer: str) -> None: user = naming.sftp_user(customer) runner.run(["userdel", user], check=False) runner.run(["rm", "-f", SSHD_DROPIN.format(customer=customer)]) runner.run(["systemctl", "reload", "ssh"])