266 lines
10 KiB
Python
266 lines
10 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
|
|
|
|
|
|
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())
|