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:
2026-07-05 15:30:28 -07:00
parent a5b8f6e7b1
commit fa4867580f
14 changed files with 1849 additions and 0 deletions

3
.claude/skills/owncloud/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Skill scratch/cache - never commit.
.cache/
__pycache__/

View File

@@ -0,0 +1,95 @@
---
name: owncloud
description: "Read-only inventory of ACG's ownCloud server (cloud.acghosting.com / 172.16.3.22, ownCloud 10.16 Community) for GPS backup verification: who has an account, per-user storage used + quota + last login, and which GuruRMM endpoints actually run the ownCloud desktop client. Triggers: owncloud, cloud.acghosting, who backs up to owncloud, owncloud usage, owncloud audit."
---
# ownCloud Skill
Read-only client for ACG's live **ownCloud 10.16.0 Community** server
(`cloud.acghosting.com`, VM `172.16.3.22`, Rocky Linux 9.6). Answers the GPS
backup-audit question: **which customers back up here and how much data do they
hold** - and, with `--rmm`, which enrolled machines actually run the ownCloud
desktop client. It never writes.
This skill is one of the backup-verification siblings alongside `seafile`,
`b2` (Backblaze) and `datto-workplace`.
## Running
```bash
OWNCLOUD=".claude/skills/owncloud/scripts/owncloud.py"
py "$OWNCLOUD" status # SSH ok + occ version / edition
py "$OWNCLOUD" users [--json] # all users + enabled/quota/used/last login
py "$OWNCLOUD" usage [--json] # per-user rollup, used GB desc (the audit view)
py "$OWNCLOUD" audit [--client NAME] [--rmm] [--json]
```
Add `--json` to `users` / `usage` / `audit` for machine-readable output.
`--host` overrides the server IP (default `172.16.3.22`).
## Access channel
There is **no ownCloud admin web / OCS account** in the vault, so the channel is
**SSH + the `occ` CLI**, not the OCS HTTP API (the OCS endpoint is reachable at
`https://cloud.acghosting.com/status.php` but we lack an OCS admin cred). The
client SSHes to `root@172.16.3.22:22` with **paramiko** (password auth,
AutoAddPolicy) and runs occ **as the web user**:
```
sudo -u apache php /var/www/owncloud/occ <cmd>
```
Credentials load lazily from the SOPS vault (never hardcoded):
`infrastructure/owncloud-vm.sops.yaml` -> `credentials.username` (root) /
`credentials.password`.
## The occ / DB commands it settled on
- **status:** `occ status --output=json` (+ `occ -V` for the display version).
- **users:** `occ user:list --show-all-attributes --output=json` -> per user:
`uid, displayName, email, quota, enabled, lastLogin, creationTime, home,
backend`. `lastLogin`/`creationTime` are Unix epoch seconds (0 = never); the
client renders them as UTC.
- **used bytes:** occ has **no `user:info` / per-user usage** on this version, so
used storage is read from ownCloud's own **filecache** (the exact figure the
UI shows). DB creds are read live from `config.php` (we already have root), then:
`SELECT s.id, fc.size FROM oc_storages s JOIN oc_filecache fc ON
fc.storage=s.numeric_id WHERE s.id LIKE 'home::%' AND fc.path='';`
Each row is `home::<uid>` -> root size in bytes.
## What the data means
- **used_bytes** is the authoritative per-account storage used; an `enabled` user
with non-zero usage is actively backing up here. `usage`/`audit` roll this up,
sorted desc, with a total.
- **quota** is a string: `default`, `none` (unlimited), or a byte/size value.
- Usage is concentrated: as of the build (2026-07-05) a single account (`pavon`)
holds ~14 TB and `sysadmin` ~2.9 TB, dwarfing the rest - ownCloud is used by a
handful of clients, not the whole GPS base.
## The "both" cross-check (`--rmm`)
Server-side (occ) tells you who has an account; `--rmm` adds the endpoint half. It
logs in to GuruRMM (`infrastructure/gururmm-server.sops.yaml`) and, for each agent
(filter with `--client`), runs a **read-only** detection PowerShell reporting
installed products / running processes / config folders matching **`ownCloud`**.
An agent is reported only if the client is actually present. This dispatches a
command to live endpoints - **always scope with `--client`** rather than sweeping
all 350+ agents.
## Notes / gotchas / limitations
- **Dirty filecache (-1):** if a user's cached root size is `-1` (cache not yet
computed), the client falls back to `du -sb <home>/files` for that one user.
That du can take minutes on a multi-hundred-GB home (timeout is generous, 900s);
`used_source` in `--json` reports `filecache` vs `du` per user. At build time
one account (`anaise`) was dirty and resolved via du (~0.6 TB).
- **Orphan storages:** the filecache can contain `home::<uid>` rows for deleted
users (e.g. a former `QWM-Sheila`); the skill keys off the live `occ user:list`,
so orphans do not appear in `users`/`usage`.
- **No OCS/web API:** deeper per-file / sharing queries would need an OCS admin
cred we do not have; occ + filecache cover the audit needs.
- Shared plumbing (repo-root resolve, vault read, GuruRMM client + endpoint
detection) lives in `.claude/scripts/backup_common.py`, used by all the
server-backup skills. Detection patterns must be precise per backend - this
skill uses `ownCloud` (not a bare `cloud`, which would over-match).

View 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())

View 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())