Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""
|
|
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()
|