Initial commit — Performance West telecom compliance platform
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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
208
scripts/workers/cdr_sftpgo_provisioner.py
Normal file
208
scripts/workers/cdr_sftpgo_provisioner.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue