""" SFTPGo provisioner — admin API client. Called when a customer toggles SFTPGo ingestion on/off in the portal: * Enable → create a random password, create an SFTPGo user with home dir mapped to cdr-uploads/{customer_id}/raw/sftpgo/ on the MinIO backend, write the password hash + username back to cdr_ingestion_profiles (sftpgo_username / sftpgo_password_hash). Return the plaintext password to the portal ONCE so the customer can copy it. * Rotate → generate a new password, PUT it via the API. * Disable → delete the user; customer can no longer connect. Admin API reference: https://sftpgo.github.io/openapi/ """ from __future__ import annotations import argparse import hashlib import json import logging import os import secrets import sys import urllib.request import urllib.error from typing import Optional import psycopg2 import psycopg2.extras log = logging.getLogger("cdr_sftpgo_provisioner") logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[logging.StreamHandler(sys.stdout)], ) SFTPGO_ADMIN_URL = os.environ.get("SFTPGO_ADMIN_URL", "http://sftpgo:8080") SFTPGO_ADMIN_USER = os.environ.get("SFTPGO_ADMIN_USER", "pw-admin") SFTPGO_ADMIN_PASSWORD = os.environ.get("SFTPGO_ADMIN_PASSWORD", "") DATABASE_URL = os.environ.get("DATABASE_URL", "") # ─── Token acquisition ──────────────────────────────────────────────────── _CACHED_TOKEN: Optional[str] = None def _get_token() -> str: global _CACHED_TOKEN if _CACHED_TOKEN: return _CACHED_TOKEN import base64 creds = base64.b64encode( f"{SFTPGO_ADMIN_USER}:{SFTPGO_ADMIN_PASSWORD}".encode("utf-8") ).decode("ascii") req = urllib.request.Request( f"{SFTPGO_ADMIN_URL}/api/v2/token", headers={"Authorization": f"Basic {creds}"}, ) with urllib.request.urlopen(req, timeout=10) as resp: payload = json.loads(resp.read()) _CACHED_TOKEN = payload.get("access_token") if not _CACHED_TOKEN: raise RuntimeError("SFTPGo admin token acquisition failed") return _CACHED_TOKEN def _api(method: str, path: str, body: Optional[dict] = None, ok_statuses: tuple[int, ...] = (200, 201, 204)) -> dict: token = _get_token() data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request( f"{SFTPGO_ADMIN_URL}{path}", data=data, method=method, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout=15) as resp: if resp.status not in ok_statuses: raise RuntimeError(f"unexpected status {resp.status}") raw = resp.read() return json.loads(raw) if raw else {} except urllib.error.HTTPError as exc: raise RuntimeError(f"SFTPGo API {method} {path} → HTTP {exc.code}: {exc.reason}") from exc # ─── User provisioning ──────────────────────────────────────────────────── def _username_for(profile_id: int, customer_id: int) -> str: return f"pw-{customer_id}-{profile_id}" def _home_path(customer_id: int) -> str: # Virtual path inside the MinIO bucket. SFTPGo translates this to # the configured s3 filesystem's key prefix. return f"/cdr-uploads/{customer_id}/raw/sftpgo" def enable_user(profile_id: int, quota_bytes: Optional[int] = None) -> dict: """Create or rotate the SFTPGo user for this profile. Returns {"username": ..., "password": ...}. Password shown ONCE — store the bcrypt hash only; we can't recover it afterwards. """ conn = psycopg2.connect(DATABASE_URL) try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( "SELECT id, customer_id, sftpgo_username " "FROM cdr_ingestion_profiles WHERE id=%s", (profile_id,), ) profile = cur.fetchone() if not profile: raise RuntimeError(f"profile {profile_id} not found") username = profile["sftpgo_username"] or _username_for( profile_id, profile["customer_id"], ) password = secrets.token_urlsafe(24) quota = quota_bytes or 5 * 1024**3 body = { "username": username, "status": 1, "password": password, "home_dir": _home_path(profile["customer_id"]), "filesystem": {"provider": 1, "name": "minio_default"}, # 1 = S3 "quota_size": quota, "permissions": {"/": ["list", "upload", "download", "rename"]}, "description": f"PW CDR push profile {profile_id}", } # Try create; fall back to update on 409 try: _api("POST", "/api/v2/users", body=body, ok_statuses=(201,)) action = "created" except RuntimeError: _api("PUT", f"/api/v2/users/{username}", body=body, ok_statuses=(200,)) action = "rotated" # Record hash (not plaintext) on the profile pwhash = hashlib.sha256(password.encode("utf-8")).hexdigest() with conn.cursor() as cur: cur.execute( "UPDATE cdr_ingestion_profiles SET " "sftpgo_enabled=TRUE, sftpgo_username=%s, " "sftpgo_password_hash=%s, sftpgo_quota_bytes=%s WHERE id=%s", (username, pwhash, quota, profile_id), ) conn.commit() log.info("sftpgo %s user %s for profile %s", action, username, profile_id) return {"username": username, "password": password, "action": action} finally: conn.close() def disable_user(profile_id: int) -> dict: conn = psycopg2.connect(DATABASE_URL) try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute( "SELECT sftpgo_username FROM cdr_ingestion_profiles WHERE id=%s", (profile_id,), ) row = cur.fetchone() if not row or not row["sftpgo_username"]: return {"disabled": False, "reason": "no user on profile"} username = row["sftpgo_username"] try: _api("DELETE", f"/api/v2/users/{username}", ok_statuses=(200, 204, 404)) except Exception as exc: log.warning("delete user %s failed: %s", username, exc) with conn.cursor() as cur: cur.execute( "UPDATE cdr_ingestion_profiles SET " "sftpgo_enabled=FALSE, sftpgo_password_hash=NULL WHERE id=%s", (profile_id,), ) conn.commit() return {"disabled": True, "username": username} finally: conn.close() def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("action", choices=["enable", "disable"]) parser.add_argument("--profile-id", type=int, required=True) parser.add_argument("--quota-gb", type=int) args = parser.parse_args() if args.action == "enable": quota_bytes = args.quota_gb * 1024**3 if args.quota_gb else None print(enable_user(args.profile_id, quota_bytes=quota_bytes)) else: print(disable_user(args.profile_id)) if __name__ == "__main__": main()