sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-05 15:29:59
This commit is contained in:
169
.claude/skills/owncloud/scripts/owncloud.py
Normal file
169
.claude/skills/owncloud/scripts/owncloud.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the `owncloud` skill - read-only ownCloud 10.16 inventory for the GPS
|
||||
backup audit (server: cloud.acghosting.com / 172.16.3.22, driven via occ over SSH).
|
||||
|
||||
Commands:
|
||||
status SSH ok + occ version / edition
|
||||
users [--json] all users + display name, enabled, quota, used, last login
|
||||
usage [--json] per-user rollup: used GB (desc) + quota + last login (audit view)
|
||||
audit [--client NAME] [--rmm] [--json]
|
||||
active users holding data; with --rmm cross-check GuruRMM
|
||||
endpoints for the ownCloud desktop client (scope with --client)
|
||||
|
||||
Server-side (occ) is the source of "who has an account + how much data"; --rmm adds
|
||||
the "which enrolled machine actually runs the ownCloud client" half.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from owncloud_client import OwncloudClient # noqa: E402
|
||||
import backup_common as bc # noqa: E402
|
||||
|
||||
GB = 1_000_000_000
|
||||
|
||||
|
||||
def _gb(n) -> float:
|
||||
try:
|
||||
return round(int(n) / GB, 2)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def cmd_status(c: OwncloudClient, args) -> int:
|
||||
s = c.server_status()
|
||||
print(f"[OK] {c.host}")
|
||||
print(json.dumps(s, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_users(c: OwncloudClient, args) -> int:
|
||||
users = c.list_users()
|
||||
users.sort(key=lambda u: int(u.get("used_bytes") or 0), reverse=True)
|
||||
if args.json:
|
||||
print(json.dumps(users, indent=2))
|
||||
return 0
|
||||
print(f"{'UID':20} {'NAME':22} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||
for u in users:
|
||||
print(f"{(u.get('uid') or '')[:20]:20} "
|
||||
f"{(u.get('display_name') or '')[:22]:22} "
|
||||
f"{'yes' if u.get('enabled') else 'NO':3} "
|
||||
f"{_gb(u.get('used_bytes')):>7} GB "
|
||||
f"{str(u.get('quota') or '-')[:12]:>12} "
|
||||
f"{str(u.get('last_login') or '-')[:19]:20}")
|
||||
print(f"\n{len(users)} users; total used "
|
||||
f"{round(sum(_gb(u.get('used_bytes')) for u in users), 1)} GB")
|
||||
return 0
|
||||
|
||||
|
||||
def _usage_rollup(c: OwncloudClient) -> list[dict]:
|
||||
users = c.list_users()
|
||||
rollup = []
|
||||
for u in users:
|
||||
rollup.append({
|
||||
"uid": u.get("uid"),
|
||||
"display_name": u.get("display_name"),
|
||||
"email": u.get("email"),
|
||||
"enabled": bool(u.get("enabled")),
|
||||
"used_bytes": int(u.get("used_bytes") or 0),
|
||||
"used_gb": _gb(u.get("used_bytes")),
|
||||
"used_source": u.get("used_source"),
|
||||
"quota": u.get("quota"),
|
||||
"last_login": u.get("last_login"),
|
||||
})
|
||||
rollup.sort(key=lambda x: x["used_bytes"], reverse=True)
|
||||
return rollup
|
||||
|
||||
|
||||
def cmd_usage(c: OwncloudClient, args) -> int:
|
||||
rollup = _usage_rollup(c)
|
||||
if args.json:
|
||||
print(json.dumps(rollup, indent=2))
|
||||
return 0
|
||||
print(f"{'UID':20} {'EN':3} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||
for r in rollup:
|
||||
print(f"{(r['uid'] or '')[:20]:20} "
|
||||
f"{'yes' if r['enabled'] else 'NO':3} "
|
||||
f"{r['used_gb']:>7} GB "
|
||||
f"{str(r['quota'] or '-')[:12]:>12} "
|
||||
f"{str(r['last_login'] or '-')[:19]:20}")
|
||||
print(f"\n{len(rollup)} users; total used "
|
||||
f"{round(sum(r['used_gb'] for r in rollup), 1)} GB")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_audit(c: OwncloudClient, args) -> int:
|
||||
rollup = _usage_rollup(c)
|
||||
# enabled users holding data are the ones actually backing up here
|
||||
active = [r for r in rollup if r["enabled"] and r["used_bytes"] > 0]
|
||||
result = {"backend": "owncloud", "server": c.host, "accounts": active}
|
||||
|
||||
if args.rmm:
|
||||
rmm = bc.RmmClient()
|
||||
rmm.login()
|
||||
checks = []
|
||||
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
|
||||
for a in agents:
|
||||
det = rmm.detect_client(a, ["ownCloud"])
|
||||
if det.get("detected"):
|
||||
checks.append(det)
|
||||
result["rmm_endpoints_with_client"] = checks
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"=== ownCloud backup accounts - {c.host} ===")
|
||||
print(f"{'UID':20} {'USED':>10} {'QUOTA':>12} {'LAST LOGIN':20}")
|
||||
for r in active:
|
||||
print(f"{(r['uid'] or '')[:20]:20} {r['used_gb']:>7} GB "
|
||||
f"{str(r['quota'] or '-')[:12]:>12} {str(r['last_login'] or '-')[:19]:20}")
|
||||
print(f"\n{len(active)} active accounts with data; "
|
||||
f"{round(sum(r['used_gb'] for r in active), 1)} GB total")
|
||||
if args.rmm:
|
||||
eps = result.get("rmm_endpoints_with_client", [])
|
||||
print(f"\n--- GuruRMM endpoints running an ownCloud client ({len(eps)}) ---")
|
||||
for e in eps:
|
||||
print(f" {e['hostname']:25} client={e.get('client_name')}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="owncloud", description="ownCloud backup inventory (GPS audit)")
|
||||
p.add_argument("--host", help="override server host/IP (default 172.16.3.22)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status")
|
||||
u = sub.add_parser("users"); u.add_argument("--json", action="store_true")
|
||||
us = sub.add_parser("usage"); us.add_argument("--json", action="store_true")
|
||||
a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
c = OwncloudClient(args.host)
|
||||
handlers = {"status": cmd_status, "users": cmd_users,
|
||||
"usage": cmd_usage, "audit": cmd_audit}
|
||||
try:
|
||||
return handlers[args.cmd](c, args)
|
||||
except bc.BackupError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
# best-effort error logging per house rule
|
||||
try:
|
||||
root = bc.resolve_root()
|
||||
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
|
||||
"owncloud", str(exc)[:200]], timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
265
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
265
.claude/skills/owncloud/scripts/owncloud_client.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user