Files
claudetools/.claude/skills/owncloud/scripts/owncloud_client.py
Howard Enos a8e76ba215 sync: auto-sync from HOWARD-HOME at 2026-07-05 20:23:39
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 20:23:39
2026-07-05 20:24:08 -07:00

511 lines
22 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
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 <uid> 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())