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>
73 lines
2.8 KiB
Python
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"])
|