#!/usr/bin/env python3 """ownCloud server client for the `owncloud` skill (GPS backup verification). Talks to ACG's live ownCloud 10.16 server (cloud.acghosting.com / 172.16.3.22) over SSH and drives the `occ` admin CLI. Read-only: enumerates users, their quota + actual storage used, and last login so the GPS backup audit can see who is actually backing up to ownCloud and how much data they hold. There is NO ownCloud admin WEB/OCS account in the vault, so the access channel is SSH (root@172.16.3.22, paramiko password auth) + `occ`, not the OCS HTTP API. `occ` must run as the web/service user, so every invocation is: sudo -u apache php /var/www/owncloud/occ Per-user "used bytes" is not exposed by occ on this version (no `user:info`). Instead we read the authoritative figure straight from ownCloud's filecache: the size ownCloud itself reports for each user's root. DB creds are read live from the server's config.php (we already have root), so nothing is duplicated and it survives a DB-password rotation. If a user's cached size is dirty (-1), we fall back to `du -sb /files` for that one user (slower, generous timeout). Credentials load lazily from the SOPS vault (never hardcoded): `infrastructure/owncloud-vm.sops.yaml` -> credentials.username / credentials.password. """ from __future__ import annotations import json import shlex import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional import paramiko # Import the shared backup helpers from .claude/scripts. _SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/owncloud _SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts sys.path.insert(0, str(_SCRIPTS_DIR)) import backup_common as bc # noqa: E402 VAULT_ENTRY = "infrastructure/owncloud-vm.sops.yaml" VAULT_FIELD_USER = "credentials.username" VAULT_FIELD_PASS = "credentials.password" DEFAULT_HOST = "172.16.3.22" SSH_PORT = 22 # occ must run as the web user (apache) with the system php against the install dir. OCC_INSTALL_DIR = "/var/www/owncloud" OCC = f"sudo -u apache php {OCC_INSTALL_DIR}/occ" CONFIG_PHP = f"{OCC_INSTALL_DIR}/config/config.php" EXEC_TIMEOUT = 60 # seconds for normal occ / mysql calls DU_TIMEOUT = 900 # du on a dirty-cache home can be hundreds of GB class OwncloudClient: def __init__(self, host: Optional[str] = None): self.host = host or DEFAULT_HOST self._ssh: Optional[paramiko.SSHClient] = None self._db: Optional[dict] = None # -- ssh ------------------------------------------------------------------- def _connect(self) -> paramiko.SSHClient: if self._ssh is not None: return self._ssh user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER) pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS) cli = paramiko.SSHClient() cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: cli.connect(self.host, port=SSH_PORT, username=user, password=pw, timeout=20, look_for_keys=False, allow_agent=False) except Exception as exc: # paramiko raises several distinct types raise bc.BackupError(f"SSH connect to {user}@{self.host} failed: {exc}") from exc self._ssh = cli return cli def run(self, cmd: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: """Run a shell command over SSH; return (exit_status, stdout, stderr).""" cli = self._connect() try: _in, out, err = cli.exec_command(cmd, timeout=timeout) stdout = out.read().decode("utf-8", "replace") stderr = err.read().decode("utf-8", "replace") rc = out.channel.recv_exit_status() except Exception as exc: raise bc.BackupError(f"SSH command failed ({cmd[:60]}...): {exc}") from exc return rc, stdout, stderr def occ(self, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: return self.run(f"{OCC} {args}", timeout=timeout) def close(self) -> None: if self._ssh is not None: try: self._ssh.close() finally: self._ssh = None # -- server status --------------------------------------------------------- def server_status(self) -> dict: """occ status (installed/version/edition) plus the `occ -V` version string.""" rc, out, err = self.occ("status --output=json") if rc != 0: raise bc.BackupError(f"occ status failed (rc={rc}): {err.strip()[:300]}") try: status = json.loads(out) except json.JSONDecodeError: raise bc.BackupError(f"occ status returned non-JSON: {out.strip()[:300]}") rc2, ver, _ = self.occ("-V") status["occ_version"] = ver.strip() if rc2 == 0 else None status["host"] = self.host return status # -- storage used (filecache primary, du fallback) ------------------------- def _db_config(self) -> dict: """Read DB name/user/password/prefix live from config.php (we have root).""" if self._db is not None: return self._db rc, out, err = self.run( "grep -E \"'db(name|user|password|tableprefix)'\" " + CONFIG_PHP) if rc != 0: raise bc.BackupError(f"reading {CONFIG_PHP} failed (rc={rc}): {err.strip()[:200]}") cfg = {"dbname": "owncloud", "dbuser": "owncloud", "dbpassword": "", "dbtableprefix": "oc_"} for line in out.splitlines(): # form: 'dbname' => 'value', if "=>" not in line: continue key, _, val = line.partition("=>") k = key.strip().strip("'\" ") v = val.strip().rstrip(",").strip().strip("'\"") if k in cfg: cfg[k] = v self._db = cfg return cfg def _mysql(self, sql: str) -> str: cfg = self._db_config() cmd = (f"MYSQL_PWD={shlex.quote(cfg['dbpassword'])} " f"mysql -N -B -u {shlex.quote(cfg['dbuser'])} " f"{shlex.quote(cfg['dbname'])} -e {shlex.quote(sql)}") rc, out, err = self.run(cmd) if rc != 0: raise bc.BackupError(f"mysql query failed (rc={rc}): {err.strip()[:200]}") return out def _filecache_sizes(self) -> dict[str, int]: """uid -> cached used bytes for each per-user home storage (ownCloud's own figure). A size of -1 means the cache is dirty; such uids are omitted here and handled by the du fallback.""" pfx = self._db_config()["dbtableprefix"] sql = (f"SELECT s.id, fc.size FROM {pfx}storages s " f"JOIN {pfx}filecache fc ON fc.storage=s.numeric_id " f"WHERE s.id LIKE 'home::%' AND fc.path='';") out = self._mysql(sql) sizes: dict[str, int] = {} for row in out.splitlines(): if "\t" not in row: continue sid, _, val = row.partition("\t") if not sid.startswith("home::"): continue uid = sid[len("home::"):] try: n = int(val.strip()) except ValueError: continue if n >= 0: sizes[uid] = n return sizes def _du_bytes(self, uid: str, home: str) -> Optional[int]: """Fallback used-bytes via du on the on-disk home (dirty-cache users only).""" if not home: return None rc, out, err = self.run( f"du -sb {shlex.quote(home + '/files')} 2>/dev/null | cut -f1", timeout=DU_TIMEOUT) try: return int(out.strip()) except (ValueError, AttributeError): return None # -- users ----------------------------------------------------------------- def list_users(self, with_usage: bool = True) -> list[dict]: """All users with attributes (uid, display name, email, quota, enabled, last login, home, backend). When with_usage, enrich each with used bytes from the filecache, falling back to du for dirty-cache users.""" rc, out, err = self.occ("user:list --show-all-attributes --output=json") if rc != 0: raise bc.BackupError(f"occ user:list failed (rc={rc}): {err.strip()[:300]}") try: raw = json.loads(out) except json.JSONDecodeError: raise bc.BackupError(f"occ user:list returned non-JSON: {out.strip()[:300]}") users: list[dict] = [] for uid, attrs in raw.items(): attrs = attrs or {} users.append({ "uid": uid, "display_name": attrs.get("displayName"), "email": attrs.get("email"), "quota": attrs.get("quota"), "enabled": _as_bool(attrs.get("enabled")), "last_login": _epoch(attrs.get("lastLogin")), "creation_time": _epoch(attrs.get("creationTime")), "backend": attrs.get("backend"), "home": attrs.get("home"), "used_bytes": None, "used_source": None, }) if with_usage: sizes = self._filecache_sizes() for u in users: uid = u["uid"] if uid in sizes: u["used_bytes"] = sizes[uid] u["used_source"] = "filecache" else: # dirty cache (-1) or no storage row: compute on disk du = self._du_bytes(uid, u.get("home")) u["used_bytes"] = du u["used_source"] = "du" if du is not None else None return users # -- occ helpers for management ops ---------------------------------------- def occ_env(self, env: dict, args: str, timeout: int = EXEC_TIMEOUT) -> tuple[int, str, str]: """Run occ with extra environment (e.g. OC_PASS) exported for the apache user. `sudo -u apache env KEY=val php occ ...` — the explicit `env` sets the var inside apache's context regardless of sudo's env_reset policy. Caveat: the value is briefly visible in `ps` on the server to root; this is occ's own documented mechanism for passwords and the box is root-only. """ prefix = " ".join(f"{k}={shlex.quote(str(v))}" for k, v in env.items()) cmd = f"sudo -u apache env {prefix} php {OCC_INSTALL_DIR}/occ {args}" return self.run(cmd, timeout=timeout) def _occ_ok(self, args: str, timeout: int = EXEC_TIMEOUT) -> str: """Run occ, raise BackupError on non-zero, return stdout.""" rc, out, err = self.occ(args, timeout=timeout) if rc != 0: label = args.split()[0] if args else "occ" raise bc.BackupError(f"occ {label} failed (rc={rc}): " f"{(err or out).strip()[:300]}") return out def _occ_env_ok(self, env: dict, args: str, timeout: int = EXEC_TIMEOUT) -> str: """occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok).""" rc, out, err = self.occ_env(env, args, timeout=timeout) if rc != 0: label = args.split()[0] if args else "occ" raise bc.BackupError(f"occ {label} failed (rc={rc}): " f"{(err or out).strip()[:300]}") return out def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any: """occ with --output=json; parse defensively so a non-JSON banner (maintenance notice, PHP deprecation, update line) surfaces as a clean BackupError rather than an uncaught JSONDecodeError traceback.""" out = self._occ_ok(args, timeout=timeout).strip() try: return json.loads(out or "null") except json.JSONDecodeError: label = args.split()[0] if args else "occ" raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}") # -- user lifecycle -------------------------------------------------------- def create_user(self, uid: str, password: str, display_name: Optional[str] = None, email: Optional[str] = None, groups: Optional[list[str]] = None) -> dict: # options first, then `--`, then the positional uid so a uid that # happens to start with '-' is never parsed by occ as an option flag. parts = ["user:add --password-from-env -n"] if display_name: parts.append(f"--display-name={shlex.quote(display_name)}") if email: parts.append(f"--email={shlex.quote(email)}") for g in (groups or []): parts.append(f"--group={shlex.quote(g)}") parts.append(f"-- {shlex.quote(uid)}") out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts)) return {"uid": uid, "output": out.strip()} def delete_user(self, uid: str, force: bool = False) -> str: return self._occ_ok(f"user:delete{' -f' if force else ''} -- " f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip() def set_user_enabled(self, uid: str, enabled: bool) -> str: verb = "user:enable" if enabled else "user:disable" return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip() def reset_password(self, uid: str, password: str) -> str: return self._occ_env_ok( {"OC_PASS": password}, f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip() def modify_user(self, uid: str, key: str, value: str) -> str: """key is one of: displayname, email (per occ user:modify).""" return self._occ_ok( f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} " f"{shlex.quote(value)}").strip() # -- quota (occ user:setting files quota) ---------------------------- def get_quota(self, uid: str) -> Optional[str]: """Return the user's quota string, or None when it is unset (= default). A non-existent user or a genuine occ failure raises BackupError - occ returns rc=1 for both 'setting unset' and 'user missing', so we key off the message ('setting does not exist' == unset).""" rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota") if rc == 0: return out.strip() or None msg = (out or err).strip() if "setting does not exist" in msg.lower(): return None raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}") def set_quota(self, uid: str, value: str) -> str: return self._occ_ok( f"user:setting --value={shlex.quote(value)} " f"-- {shlex.quote(uid)} files quota").strip() def reset_quota(self, uid: str) -> str: return self._occ_ok( f"user:setting --delete -- {shlex.quote(uid)} files quota").strip() # -- groups ---------------------------------------------------------------- def list_groups(self) -> Any: return self._occ_json("group:list --output=json") def group_members(self, group: str) -> Any: return self._occ_json( f"group:list-members --output=json -- {shlex.quote(group)}") def user_groups(self, uid: str) -> Any: return self._occ_json( f"user:list-groups --output=json -- {shlex.quote(uid)}") def create_group(self, name: str) -> str: return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip() def delete_group(self, name: str) -> str: return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip() def add_group_members(self, group: str, members: list[str]) -> str: # --member= binds each value even if it starts with '-'. ms = " ".join(f"--member={shlex.quote(m)}" for m in members) return self._occ_ok( f"group:add-member {ms} -- {shlex.quote(group)}").strip() def remove_group_members(self, group: str, members: list[str]) -> str: ms = " ".join(f"--member={shlex.quote(m)}" for m in members) return self._occ_ok( f"group:remove-member {ms} -- {shlex.quote(group)}").strip() # -- shares (read-only, straight from the DB; occ has no share list) -------- SHARE_TYPES = {0: "user", 1: "group", 3: "public_link", 4: "email", 6: "federated"} def list_shares(self, uid: Optional[str] = None) -> list[dict]: # NB: uid is NOT inlined into SQL. MySQL honours backslash escapes by # default, so ''-doubling would not safely contain a hostile/odd uid # (and occ allows "'" in uids). Fetch all rows, filter in Python. pfx = self._db_config()["dbtableprefix"] sql = (f"SELECT s.id, s.share_type, s.share_with, s.uid_owner, " f"s.file_target, s.permissions, s.stime, fc.path " f"FROM {pfx}share s " f"LEFT JOIN {pfx}filecache fc ON fc.fileid=s.file_source " f"ORDER BY s.stime DESC;") out = self._mysql(sql) shares: list[dict] = [] for row in out.splitlines(): f = [None if v in ("NULL", "") else v for v in row.split("\t")] if len(f) < 8: continue if uid and uid not in (f[2], f[3]): # share_with / owner continue st = _int(f[1]) shares.append({ "id": _int(f[0]), "share_type": self.SHARE_TYPES.get(st, str(st)), "share_with": f[2], "owner": f[3], "target": f[4], "permissions": _int(f[5]), "created": _epoch(f[6]), "path": f[7], }) return shares # -- server / apps / config ------------------------------------------------ def maintenance_mode(self, on: bool) -> str: return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip() def maintenance_status(self) -> bool: # reuse config_get so a genuine occ failure raises (not a false 'off'); # the maintenance key is always set, so None would itself be anomalous. val = self.config_get("maintenance") return (val or "").strip().lower() in ("true", "1", "yes") def list_apps(self) -> Any: return self._occ_json("app:list --output=json") def enable_app(self, app: str) -> str: return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip() def disable_app(self, app: str) -> str: return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip() def config_get(self, name: str) -> Optional[str]: """Return the system config value, or None when the key is genuinely unset (occ rc=1 with empty output). A non-zero rc that carries an error message is a real failure and raises, so callers never mistake a broken occ read for 'value is unset'.""" rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}") if rc == 0: return out.strip() msg = (err or out).strip() if not msg: return None raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}") def config_list(self) -> Any: return self._occ_json("config:list system --output=json") def config_set(self, name: str, value: str, vtype: str = "string") -> str: return self._occ_ok( f"config:system:set {shlex.quote(name)} " f"--value={shlex.quote(value)} --type={shlex.quote(vtype)}").strip() # -- files admin ----------------------------------------------------------- def files_scan(self, uid: Optional[str] = None, all_users: bool = False, path: Optional[str] = None) -> str: opts = "files:scan" if path: opts += f" --path={shlex.quote(path)}" if all_users: args = f"{opts} --all" elif uid: args = f"{opts} -- {shlex.quote(uid)}" else: raise bc.BackupError("files_scan requires a uid or all_users=True") return self._occ_ok(args, timeout=DU_TIMEOUT).strip() def transfer_ownership(self, src: str, dst: str, path: Optional[str] = None) -> str: args = "files:transfer-ownership -s" if path: args += f" --path={shlex.quote(path)}" args += f" -- {shlex.quote(src)} {shlex.quote(dst)}" return self._occ_ok(args, timeout=DU_TIMEOUT).strip() def trashbin_cleanup(self, users: list[str]) -> str: us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else "" return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip() def versions_cleanup(self, users: list[str]) -> str: us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else "" return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip() def _int(v: Any) -> Optional[int]: try: return int(str(v).strip()) except (TypeError, ValueError): return None def _epoch(v: Any) -> Optional[str]: """occ emits lastLogin/creationTime as Unix epoch seconds; 0 = never.""" try: n = int(v) except (TypeError, ValueError): return None if n <= 0: return None return datetime.fromtimestamp(n, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") def _as_bool(v: Any) -> bool: if isinstance(v, bool): return v if isinstance(v, str): return v.strip().lower() in ("true", "yes", "1", "enabled") return bool(v) def main() -> int: c = OwncloudClient() try: s = c.server_status() print(f"[OK] ownCloud reachable at {c.host}; version={s.get('versionstring')} " f"edition={s.get('edition')}") return 0 except bc.BackupError as exc: print(f"[ERROR] {exc}", file=sys.stderr) return 1 finally: c.close() if __name__ == "__main__": raise SystemExit(main())