platform/control-panel/src/heleos/sftp.py
Bart Van Geyt fe4cbbe36a Phase 4: provisioning CLI (heleosctl)
Python control-panel package driving the full provisioning flow from a
site's site.yaml (docs/05):

- provision: ZFS web dataset + ownership, per-site DB + least-priv user,
  generated .env encrypted to secrets.enc.yaml (SOPS/age), render the
  profile templates + persist site.yaml, per-customer chrooted SFTP
  account, docker compose up.
- deprovision (gated: data destroyed only with --purge, after a final
  backup), backup (ZFS snapshot + mariadb-dump), restore (rollback +
  import), render (preview), list.

Design: one command/file runner with a real --dry-run (prints every
action, redacts secrets); idempotent steps; Config + Site validation
mirroring docs/03; passwords never logged.

Modules: cli, config, naming, context, render, runner, zfs, database,
secrets, sftp, compose, provision, backup. Plus pyproject (heleosctl
entry point), config.example.yaml, an example site, and a README.

Tests: 22 pure-logic unit tests (naming, config validation, template
render across all profiles + db on/off) — all passing. Full provision and
deprovision verified end-to-end in --dry-run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:18:36 +02:00

73 lines
2.8 KiB
Python

"""Per-customer SFTP account + web-root ownership.
One chrooted SFTP user per customer (``sftp_<customer>``), 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"])