Files
claudetools/.claude/skills/owncloud/scripts/owncloud_client.py
Howard Enos 976bfe7957 sync: auto-sync from HOWARD-HOME at 2026-07-05 19:59:52
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 19:59:52
2026-07-05 20:00:20 -07:00

470 lines
20 KiB
Python

#!/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 <cmd>
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 <home>/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
# -- 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:
parts = [f"user:add {shlex.quote(uid)} --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"-g {shlex.quote(g)}")
rc, out, err = self.occ_env({"OC_PASS": password}, " ".join(parts))
if rc != 0:
raise bc.BackupError(f"user:add {uid} failed (rc={rc}): "
f"{(err or out).strip()[:300]}")
return {"uid": uid, "output": out.strip()}
def delete_user(self, uid: str, force: bool = False) -> str:
return self._occ_ok(f"user:delete {shlex.quote(uid)}"
+ (" -f" if force else ""), 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:
rc, out, err = self.occ_env(
{"OC_PASS": password},
f"user:resetpassword {shlex.quote(uid)} --password-from-env")
if rc != 0:
raise bc.BackupError(f"user:resetpassword {uid} failed (rc={rc}): "
f"{(err or out).strip()[:300]}")
return out.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)} {shlex.quote(value)}").strip()
# -- quota (occ user:setting <uid> files quota) ----------------------------
def get_quota(self, uid: str) -> Optional[str]:
rc, out, err = self.occ(f"user:setting {shlex.quote(uid)} files quota")
return out.strip() or None if rc == 0 else None
def set_quota(self, uid: str, value: str) -> str:
return self._occ_ok(
f"user:setting {shlex.quote(uid)} files quota --value={shlex.quote(value)}").strip()
def reset_quota(self, uid: str) -> str:
return self._occ_ok(
f"user:setting {shlex.quote(uid)} files quota --delete").strip()
# -- groups ----------------------------------------------------------------
def list_groups(self) -> Any:
return json.loads(self._occ_ok("group:list --output=json") or "[]")
def group_members(self, group: str) -> Any:
return json.loads(self._occ_ok(
f"group:list-members {shlex.quote(group)} --output=json") or "[]")
def user_groups(self, uid: str) -> Any:
return json.loads(self._occ_ok(
f"user:list-groups {shlex.quote(uid)} --output=json") or "[]")
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:
ms = " ".join(f"-m {shlex.quote(m)}" for m in members)
return self._occ_ok(f"group:add-member {shlex.quote(group)} {ms}").strip()
def remove_group_members(self, group: str, members: list[str]) -> str:
ms = " ".join(f"-m {shlex.quote(m)}" for m in members)
return self._occ_ok(f"group:remove-member {shlex.quote(group)} {ms}").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:
rc, out, err = self.occ("config:system:get maintenance")
return rc == 0 and out.strip().lower() in ("true", "1", "yes")
def list_apps(self) -> Any:
return json.loads(self._occ_ok("app:list --output=json") or "{}")
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]:
rc, out, err = self.occ(f"config:system:get {shlex.quote(name)}")
return out.strip() if rc == 0 else None
def config_list(self) -> Any:
return json.loads(self._occ_ok("config:list system --output=json") or "{}")
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:
if all_users:
args = "files:scan --all"
elif uid:
args = f"files:scan {shlex.quote(uid)}"
else:
raise bc.BackupError("files_scan requires a uid or all_users=True")
if path:
args += f" --path={shlex.quote(path)}"
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
def transfer_ownership(self, src: str, dst: str,
path: Optional[str] = None) -> str:
args = (f"files:transfer-ownership {shlex.quote(src)} "
f"{shlex.quote(dst)} -s")
if path:
args += f" --path={shlex.quote(path)}"
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)
return self._occ_ok(f"trashbin:cleanup {us}".strip(),
timeout=DU_TIMEOUT).strip()
def versions_cleanup(self, users: list[str]) -> str:
us = " ".join(shlex.quote(u) for u in users)
return self._occ_ok(f"versions:cleanup {us}".strip(),
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())