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
This commit is contained in:
2026-07-05 20:24:08 -07:00
parent 976bfe7957
commit a8e76ba215
10 changed files with 292 additions and 108 deletions

View File

@@ -23,7 +23,10 @@
| Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) | | Run a command / investigate / script on an RMM agent | `/rmm` (find the host first with `rmm-search`) |
| M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` | | M365 investigation/remediation, breach check, mailbox/inbox-rule/forwarding audit, tenant sweep | `remediation-tool` |
| Onboard a new M365 tenant to the remediation app suite | `onboard365` | | Onboard a new M365 tenant to the remediation app suite | `onboard365` |
| Backblaze / B2 storage, buckets, backup storage cost | `b2` | | Is client X backing up? backup status/failures/stale plans; MSP360 backup user/company/license provisioning | `msp360` (authoritative backup layer — check here, not bucket contents) |
| Backblaze / B2 storage, buckets, backup storage cost (destination side) | `b2` |
| Who backs up to Seafile / ownCloud / Datto Workplace (per-backend inventory + endpoint detection) | `seafile` / `owncloud` / `datto-workplace` |
| Provision/admin Seafile (SeaCloud) — create/deactivate/delete account, set quota, create/transfer/delete library, share/unshare | `seafile` (writes preview by default; `--confirm` to apply) |
| Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` | | Bitdefender / GravityZone AV — endpoints, sweeps, policies, quarantine, EDR | `bitdefender` |
| Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` | | Datto EDR / Datto AV — detections, isolate, scan, agent deploy | `datto-edr` |
| VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` | | VoIP — PacketDial / OITVOIP / NetSapiens domains, users, DIDs, queues, CDRs | `packetdial` |

View File

@@ -24,6 +24,7 @@ import subprocess
import sys import sys
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -93,18 +94,26 @@ def vault_get_field(entry: str, field: str) -> str:
# --- minimal JSON HTTP -------------------------------------------------------- # --- minimal JSON HTTP --------------------------------------------------------
def http_json(method: str, url: str, *, headers: Optional[dict] = None, def http_json(method: str, url: str, *, headers: Optional[dict] = None,
body: Optional[dict] = None, timeout: float = HTTP_TIMEOUT, body: Optional[dict] = None, form: Optional[dict] = None,
timeout: float = HTTP_TIMEOUT,
basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]: basic: Optional[tuple[str, str]] = None) -> tuple[int, Any]:
"""Do an HTTP request and parse a JSON response. Returns (status, parsed). """Do an HTTP request and parse a JSON response. Returns (status, parsed).
4xx/5xx are returned (not raised) so callers can inspect the error body. `body` sends a JSON payload; `form` sends application/x-www-form-urlencoded
Transport failures raise BackupError. (list values expand via doseq, e.g. repeated fields the Seafile admin share
endpoint reads with getlist). Pass at most one. 4xx/5xx are returned (not
raised) so callers can inspect the error body; transport failures raise.
""" """
hdrs = dict(headers or {}) hdrs = dict(headers or {})
data = None data = None
if body is not None and form is not None:
raise BackupError("http_json: pass body OR form, not both")
if body is not None: if body is not None:
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
hdrs.setdefault("Content-Type", "application/json") hdrs.setdefault("Content-Type", "application/json")
elif form is not None:
data = urllib.parse.urlencode(form, doseq=True).encode("utf-8")
hdrs.setdefault("Content-Type", "application/x-www-form-urlencoded")
if basic is not None: if basic is not None:
import base64 import base64
tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii") tok = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode("ascii")

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Non-destructive verification for the owncloud skill.
# - every WRITE command is run WITHOUT --confirm -> must refuse (rc 3), no SSH write
# - hard-fail guards are run (validation precedes the gate, so no execution) -> rc 2
# - read-only commands must succeed (rc 0) and JSON reads must parse
# NOTHING here runs a state change on the server.
OC=".claude/skills/owncloud/scripts/owncloud.py"
pass=0; fail=0
chk() { # chk <expected_rc> <label> -- <cmd...>
local exp="$1" label="$2"; shift 3
"$@" >/dev/null 2>&1; local rc=$?
if [ "$rc" = "$exp" ]; then printf ' [PASS] %-40s (rc %s)\n' "$label" "$rc"; pass=$((pass+1))
else printf ' [FAIL] %-40s (got rc %s, want %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1)); fi
}
jchk() { # jchk <label> -- <cmd...> (stdout must be valid JSON)
local label="$1"; shift 2
if "$@" 2>/dev/null | py -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
printf ' [PASS] %-40s (valid JSON)\n' "$label"; pass=$((pass+1))
else printf ' [FAIL] %-40s (bad JSON)\n' "$label"; fail=$((fail+1)); fi
}
echo "== WRITE commands without --confirm must REFUSE (rc 3) =="
chk 3 "user-add" -- py "$OC" user-add zz.dry --gen-password
chk 3 "user-delete" -- py "$OC" user-delete zz.dry
chk 3 "user-enable" -- py "$OC" user-enable zz.dry
chk 3 "user-disable" -- py "$OC" user-disable zz.dry
chk 3 "user-reset-password" -- py "$OC" user-reset-password zz.dry --gen-password
chk 3 "user-modify" -- py "$OC" user-modify zz.dry --email a@b.com
chk 3 "quota-set" -- py "$OC" quota-set zz.dry "5 GB"
chk 3 "quota-reset" -- py "$OC" quota-reset zz.dry
chk 3 "group-add" -- py "$OC" group-add zz.dry
chk 3 "group-delete" -- py "$OC" group-delete zz.dry
chk 3 "group-add-member" -- py "$OC" group-add-member zz.dry -m sysadmin
chk 3 "group-remove-member" -- py "$OC" group-remove-member zz.dry -m sysadmin
chk 3 "maintenance on" -- py "$OC" maintenance on
chk 3 "maintenance off" -- py "$OC" maintenance off
chk 3 "app-enable" -- py "$OC" app-enable activity
chk 3 "app-disable" -- py "$OC" app-disable activity
chk 3 "config-set" -- py "$OC" config-set zz.key --value v
chk 3 "files-scan --user" -- py "$OC" files-scan --user zz.dry
chk 3 "files-scan --all" -- py "$OC" files-scan --all
chk 3 "transfer-ownership" -- py "$OC" transfer-ownership a b
chk 3 "trashbin-cleanup" -- py "$OC" trashbin-cleanup --user zz.dry
chk 3 "versions-cleanup" -- py "$OC" versions-cleanup --user zz.dry
echo "== hard-fail guards (validation precedes the gate; no execution) rc 2 =="
chk 2 "user-add bad uid" -- py "$OC" user-add "bad uid!" --gen-password --confirm
chk 2 "user-add no password" -- py "$OC" user-add validuid --confirm
chk 2 "user-modify no fields" -- py "$OC" user-modify zz.dry --confirm
chk 2 "files-scan no target" -- py "$OC" files-scan --confirm
chk 2 "trashbin-cleanup no target"-- py "$OC" trashbin-cleanup --confirm
chk 2 "versions-cleanup no target"-- py "$OC" versions-cleanup --confirm
echo "== read-only commands must succeed (rc 0) =="
chk 0 "status" -- py "$OC" status
chk 0 "groups" -- py "$OC" groups
chk 0 "group-members" -- py "$OC" group-members admin
chk 0 "user-groups" -- py "$OC" user-groups sysadmin
chk 0 "shares" -- py "$OC" shares
chk 0 "shares --user" -- py "$OC" shares --user sysadmin
chk 0 "apps" -- py "$OC" apps
chk 0 "maintenance status" -- py "$OC" maintenance status
chk 0 "config-get key" -- py "$OC" config-get maintenance
chk 0 "quota (read)" -- py "$OC" quota sysadmin
echo "== JSON read outputs must parse =="
jchk "groups --json" -- py "$OC" groups --json
jchk "apps --json" -- py "$OC" apps --json
jchk "shares --json" -- py "$OC" shares --json
echo
echo "==== $pass passed, $fail failed ===="
[ "$fail" = 0 ]

View File

@@ -55,7 +55,7 @@ def _gen_password(n: int = 20) -> str:
return "".join(secrets.choice(alpha) for _ in range(n)) return "".join(secrets.choice(alpha) for _ in range(n))
def _resolve_password(args, *, allow_gen: bool = True): def _resolve_password(args):
"""Return (password, was_generated) from --password / --password-env / --gen-password. """Return (password, was_generated) from --password / --password-env / --gen-password.
Prefer --password-env or --gen-password: a literal --password lands in shell Prefer --password-env or --gen-password: a literal --password lands in shell
history and `ps` on the operator's machine.""" history and `ps` on the operator's machine."""
@@ -67,15 +67,18 @@ def _resolve_password(args, *, allow_gen: bool = True):
if not v: if not v:
raise SystemExit(f"[ERROR] env var {env_var} is unset/empty") raise SystemExit(f"[ERROR] env var {env_var} is unset/empty")
return v, False return v, False
if allow_gen and getattr(args, "gen_password", False): if getattr(args, "gen_password", False):
return _gen_password(), True return _gen_password(), True
return None, False return None, False
def _vault_reminder(uid: str) -> None: def _vault_reminder(uid: str, stream=None) -> None:
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:") out = stream or sys.stdout
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:",
file=out)
print(f" bash .claude/scripts/vault.sh set-field " print(f" bash .claude/scripts/vault.sh set-field "
f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'") f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'",
file=out)
def cmd_status(c: OwncloudClient, args) -> int: def cmd_status(c: OwncloudClient, args) -> int:
@@ -204,13 +207,20 @@ def cmd_user_add(c: OwncloudClient, args) -> int:
if not _gated(desc, args.confirm): if not _gated(desc, args.confirm):
return 3 return 3
res = c.create_user(args.uid, pw, args.display_name, args.email, args.group) res = c.create_user(args.uid, pw, args.display_name, args.email, args.group)
if args.json:
# keep stdout parseable: password + vault reminder go to stderr
if generated:
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
"store in vault", file=sys.stderr)
_vault_reminder(args.uid, stream=sys.stderr)
print(json.dumps({"uid": args.uid, "created": True,
"generated_password": pw if generated else None,
"detail": res}, indent=2))
return 0
print(f"[OK] created user '{args.uid}'") print(f"[OK] created user '{args.uid}'")
if generated: if generated:
print(f"[CRITICAL] generated password (shown once): {pw}") print(f"[CRITICAL] generated password (shown once): {pw}")
_vault_reminder(args.uid) _vault_reminder(args.uid)
if args.json:
print(json.dumps({"uid": args.uid, "created": True,
"generated_password": generated, "detail": res}, indent=2))
return 0 return 0
@@ -246,13 +256,19 @@ def cmd_user_reset_password(c: OwncloudClient, args) -> int:
if not _gated(f"reset password for user '{args.uid}'", args.confirm): if not _gated(f"reset password for user '{args.uid}'", args.confirm):
return 3 return 3
c.reset_password(args.uid, pw) c.reset_password(args.uid, pw)
if args.json:
if generated:
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
"store in vault", file=sys.stderr)
_vault_reminder(args.uid, stream=sys.stderr)
print(json.dumps({"uid": args.uid, "password_reset": True,
"generated_password": pw if generated else None},
indent=2))
return 0
print(f"[OK] reset password for '{args.uid}'") print(f"[OK] reset password for '{args.uid}'")
if generated: if generated:
print(f"[CRITICAL] generated password (shown once): {pw}") print(f"[CRITICAL] generated password (shown once): {pw}")
_vault_reminder(args.uid) _vault_reminder(args.uid)
if args.json:
print(json.dumps({"uid": args.uid, "password_reset": True,
"generated_password": generated}, indent=2))
return 0 return 0

View File

@@ -250,84 +250,115 @@ class OwncloudClient:
f"{(err or out).strip()[:300]}") f"{(err or out).strip()[:300]}")
return out 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 -------------------------------------------------------- # -- user lifecycle --------------------------------------------------------
def create_user(self, uid: str, password: str, def create_user(self, uid: str, password: str,
display_name: Optional[str] = None, display_name: Optional[str] = None,
email: Optional[str] = None, email: Optional[str] = None,
groups: Optional[list[str]] = None) -> dict: groups: Optional[list[str]] = None) -> dict:
parts = [f"user:add {shlex.quote(uid)} --password-from-env -n"] # 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: if display_name:
parts.append(f"--display-name={shlex.quote(display_name)}") parts.append(f"--display-name={shlex.quote(display_name)}")
if email: if email:
parts.append(f"--email={shlex.quote(email)}") parts.append(f"--email={shlex.quote(email)}")
for g in (groups or []): for g in (groups or []):
parts.append(f"-g {shlex.quote(g)}") parts.append(f"--group={shlex.quote(g)}")
rc, out, err = self.occ_env({"OC_PASS": password}, " ".join(parts)) parts.append(f"-- {shlex.quote(uid)}")
if rc != 0: out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
raise bc.BackupError(f"user:add {uid} failed (rc={rc}): "
f"{(err or out).strip()[:300]}")
return {"uid": uid, "output": out.strip()} return {"uid": uid, "output": out.strip()}
def delete_user(self, uid: str, force: bool = False) -> str: def delete_user(self, uid: str, force: bool = False) -> str:
return self._occ_ok(f"user:delete {shlex.quote(uid)}" return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
+ (" -f" if force else ""), timeout=DU_TIMEOUT).strip() f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip()
def set_user_enabled(self, uid: str, enabled: bool) -> str: def set_user_enabled(self, uid: str, enabled: bool) -> str:
verb = "user:enable" if enabled else "user:disable" verb = "user:enable" if enabled else "user:disable"
return self._occ_ok(f"{verb} {shlex.quote(uid)}").strip() return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip()
def reset_password(self, uid: str, password: str) -> str: def reset_password(self, uid: str, password: str) -> str:
rc, out, err = self.occ_env( return self._occ_env_ok(
{"OC_PASS": password}, {"OC_PASS": password},
f"user:resetpassword {shlex.quote(uid)} --password-from-env") f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip()
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: def modify_user(self, uid: str, key: str, value: str) -> str:
"""key is one of: displayname, email (per occ user:modify).""" """key is one of: displayname, email (per occ user:modify)."""
return self._occ_ok( return self._occ_ok(
f"user:modify {shlex.quote(uid)} {shlex.quote(key)} {shlex.quote(value)}").strip() f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} "
f"{shlex.quote(value)}").strip()
# -- quota (occ user:setting <uid> files quota) ---------------------------- # -- quota (occ user:setting <uid> files quota) ----------------------------
def get_quota(self, uid: str) -> Optional[str]: def get_quota(self, uid: str) -> Optional[str]:
rc, out, err = self.occ(f"user:setting {shlex.quote(uid)} files quota") """Return the user's quota string, or None when it is unset (= default).
return out.strip() or None if rc == 0 else None 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: def set_quota(self, uid: str, value: str) -> str:
return self._occ_ok( return self._occ_ok(
f"user:setting {shlex.quote(uid)} files quota --value={shlex.quote(value)}").strip() f"user:setting --value={shlex.quote(value)} "
f"-- {shlex.quote(uid)} files quota").strip()
def reset_quota(self, uid: str) -> str: def reset_quota(self, uid: str) -> str:
return self._occ_ok( return self._occ_ok(
f"user:setting {shlex.quote(uid)} files quota --delete").strip() f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()
# -- groups ---------------------------------------------------------------- # -- groups ----------------------------------------------------------------
def list_groups(self) -> Any: def list_groups(self) -> Any:
return json.loads(self._occ_ok("group:list --output=json") or "[]") return self._occ_json("group:list --output=json")
def group_members(self, group: str) -> Any: def group_members(self, group: str) -> Any:
return json.loads(self._occ_ok( return self._occ_json(
f"group:list-members {shlex.quote(group)} --output=json") or "[]") f"group:list-members --output=json -- {shlex.quote(group)}")
def user_groups(self, uid: str) -> Any: def user_groups(self, uid: str) -> Any:
return json.loads(self._occ_ok( return self._occ_json(
f"user:list-groups {shlex.quote(uid)} --output=json") or "[]") f"user:list-groups --output=json -- {shlex.quote(uid)}")
def create_group(self, name: str) -> str: def create_group(self, name: str) -> str:
return self._occ_ok(f"group:add {shlex.quote(name)}").strip() return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip()
def delete_group(self, name: str) -> str: def delete_group(self, name: str) -> str:
return self._occ_ok(f"group:delete {shlex.quote(name)}").strip() return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip()
def add_group_members(self, group: str, members: list[str]) -> str: def add_group_members(self, group: str, members: list[str]) -> str:
ms = " ".join(f"-m {shlex.quote(m)}" for m in members) # --member= binds each value even if it starts with '-'.
return self._occ_ok(f"group:add-member {shlex.quote(group)} {ms}").strip() 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: def remove_group_members(self, group: str, members: list[str]) -> str:
ms = " ".join(f"-m {shlex.quote(m)}" for m in members) ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
return self._occ_ok(f"group:remove-member {shlex.quote(group)} {ms}").strip() 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) -------- # -- shares (read-only, straight from the DB; occ has no share list) --------
SHARE_TYPES = {0: "user", 1: "group", 3: "public_link", SHARE_TYPES = {0: "user", 1: "group", 3: "public_link",
@@ -369,24 +400,35 @@ class OwncloudClient:
return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip() return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip()
def maintenance_status(self) -> bool: def maintenance_status(self) -> bool:
rc, out, err = self.occ("config:system:get maintenance") # reuse config_get so a genuine occ failure raises (not a false 'off');
return rc == 0 and out.strip().lower() in ("true", "1", "yes") # 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: def list_apps(self) -> Any:
return json.loads(self._occ_ok("app:list --output=json") or "{}") return self._occ_json("app:list --output=json")
def enable_app(self, app: str) -> str: def enable_app(self, app: str) -> str:
return self._occ_ok(f"app:enable {shlex.quote(app)}").strip() return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip()
def disable_app(self, app: str) -> str: def disable_app(self, app: str) -> str:
return self._occ_ok(f"app:disable {shlex.quote(app)}").strip() return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip()
def config_get(self, name: str) -> Optional[str]: def config_get(self, name: str) -> Optional[str]:
rc, out, err = self.occ(f"config:system:get {shlex.quote(name)}") """Return the system config value, or None when the key is genuinely
return out.strip() if rc == 0 else None 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: def config_list(self) -> Any:
return json.loads(self._occ_ok("config:list system --output=json") or "{}") return self._occ_json("config:list system --output=json")
def config_set(self, name: str, value: str, vtype: str = "string") -> str: def config_set(self, name: str, value: str, vtype: str = "string") -> str:
return self._occ_ok( return self._occ_ok(
@@ -396,33 +438,32 @@ class OwncloudClient:
# -- files admin ----------------------------------------------------------- # -- files admin -----------------------------------------------------------
def files_scan(self, uid: Optional[str] = None, all_users: bool = False, def files_scan(self, uid: Optional[str] = None, all_users: bool = False,
path: Optional[str] = None) -> str: path: Optional[str] = None) -> str:
opts = "files:scan"
if path:
opts += f" --path={shlex.quote(path)}"
if all_users: if all_users:
args = "files:scan --all" args = f"{opts} --all"
elif uid: elif uid:
args = f"files:scan {shlex.quote(uid)}" args = f"{opts} -- {shlex.quote(uid)}"
else: else:
raise bc.BackupError("files_scan requires a uid or all_users=True") 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() return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
def transfer_ownership(self, src: str, dst: str, def transfer_ownership(self, src: str, dst: str,
path: Optional[str] = None) -> str: path: Optional[str] = None) -> str:
args = (f"files:transfer-ownership {shlex.quote(src)} " args = "files:transfer-ownership -s"
f"{shlex.quote(dst)} -s")
if path: if path:
args += f" --path={shlex.quote(path)}" args += f" --path={shlex.quote(path)}"
args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
return self._occ_ok(args, timeout=DU_TIMEOUT).strip() return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
def trashbin_cleanup(self, users: list[str]) -> str: def trashbin_cleanup(self, users: list[str]) -> str:
us = " ".join(shlex.quote(u) for u in users) us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
return self._occ_ok(f"trashbin:cleanup {us}".strip(), return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()
timeout=DU_TIMEOUT).strip()
def versions_cleanup(self, users: list[str]) -> str: def versions_cleanup(self, users: list[str]) -> str:
us = " ".join(shlex.quote(u) for u in users) us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
return self._occ_ok(f"versions:cleanup {us}".strip(), return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()
timeout=DU_TIMEOUT).strip()
def _int(v: Any) -> Optional[int]: def _int(v: Any) -> Optional[int]:

View File

@@ -62,10 +62,13 @@ py "$SEAFILE" unshare <repo_id> --to a@acme.com --confirm
py "$SEAFILE" share <repo_id> --to <group_id> --group --perm r --confirm py "$SEAFILE" share <repo_id> --to <group_id> --group --perm r --confirm
``` ```
**Quota unit:** `--quota-gb` / `--gb` are **GiB** — Seafile stores quota in MB and **Quota unit:** `--quota-gb` / `--gb` are **decimal GB** — deliberately the same
its admin UI treats the field as GiB (1 GiB = 1024 MB). The preview always shows unit the read commands (`users` / `usage` / `audit`) display, so a quota you set
the exact MB value it will send (e.g. `102400 MB (= 100 GiB)`) so you can verify round-trips with the quota you later see here. Internally Seafile stores quota in
before `--confirm`. MB (1 MB = 1 MiB); the preview shows the exact MB it will send (e.g.
`500 GB (-> 476837 MB stored)`) so you can verify before `--confirm`. Caveat:
Seafile's own web admin UI renders quota in binary GiB, so the same quota reads
~7% smaller there — trust this tool's `users` view for the round-trip.
**Any password you set is a credential**`user-create`/`user-set` print a **Any password you set is a credential**`user-create`/`user-set` print a
reminder; store it in the SOPS vault via the `vault` skill (house rule). Never reminder; store it in the SOPS vault via the `vault` skill (house rule). Never

View File

@@ -15,7 +15,7 @@ Commands:
Write ops (admin) -- ALL preview by default; add --confirm to apply: Write ops (admin) -- ALL preview by default; add --confirm to apply:
user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive] user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive]
user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P] user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P]
quota EMAIL --gb G set storage quota (GiB) quota EMAIL --gb G set storage quota (decimal GB, as shown by `users`)
user-delete EMAIL remove account (irreversible) user-delete EMAIL remove account (irreversible)
lib-create NAME [--owner EMAIL] create a library lib-create NAME [--owner EMAIL] create a library
lib-transfer REPO_ID --to EMAIL transfer ownership lib-transfer REPO_ID --to EMAIL transfer ownership
@@ -197,17 +197,28 @@ def cmd_audit(c: SeafileClient, args) -> int:
# --- write ops (gated) -------------------------------------------------------- # --- write ops (gated) --------------------------------------------------------
# Seafile stores user quota in MB (binary unit); its own admin UI treats the quota # Quota units: Seafile's admin API takes quota_total in MB, where 1 MB == 1 MiB
# field as GiB, i.e. 1 GiB = 1024 MB. We expose --gb and convert the same way. # (get_file_size_unit('MB') == 2**20). This tool's read commands (users/usage/audit)
GIB_MB = 1024 # render quota as decimal GB (bytes / 1e9, see _gb/_fmt_quota). To make a quota you
# SET round-trip with the quota you later SEE here, --gb is decimal GB and we convert
# to the stored MB count accordingly. (Note: Seafile's own web admin UI shows quota
# in binary GiB, so the same quota reads ~7% smaller there.)
MB_BYTES = 1 << 20 # Seafile's MB unit (1 MiB)
GB_BYTES = 1_000_000_000 # this tool's display GB (matches _gb)
def _gb_to_mb(gb) -> int: def _gb_to_mb(gb) -> int:
return int(round(float(gb) * GIB_MB)) return int(round(float(gb) * GB_BYTES / MB_BYTES))
def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -> int: def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None,
"""Print the plan; execute only with --confirm. Preview is fully offline.""" on_result=None) -> int:
"""Print the plan; execute only with --confirm. Preview is fully offline.
`on_result(result) -> int`: optional post-execute check that may print its own
warnings and return a non-zero exit code (e.g. partial-success responses that
still come back HTTP 200). A non-zero return suppresses the [OK] line.
"""
print(f"[PLAN] {label}") print(f"[PLAN] {label}")
for k, v in detail.items(): for k, v in detail.items():
print(f" {k}: {v}") print(f" {k}: {v}")
@@ -215,6 +226,10 @@ def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -
print("[PREVIEW] not executed. Re-run with --confirm to apply.") print("[PREVIEW] not executed. Re-run with --confirm to apply.")
return 0 return 0
result = fn() result = fn()
if on_result is not None:
rc = on_result(result)
if rc:
return rc
print(f"[OK] {label}") print(f"[OK] {label}")
if reminder: if reminder:
print(f"[REMINDER] {reminder}") print(f"[REMINDER] {reminder}")
@@ -229,7 +244,7 @@ def cmd_user_create(c: SeafileClient, args) -> int:
if args.name: if args.name:
detail["name"] = args.name detail["name"] = args.name
if quota_mb is not None: if quota_mb is not None:
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)" detail["quota"] = f"{args.quota_gb} GB (-> {quota_mb} MB stored)"
if args.role: if args.role:
detail["role"] = args.role detail["role"] = args.role
return _do_write( return _do_write(
@@ -250,7 +265,7 @@ def cmd_user_set(c: SeafileClient, args) -> int:
if is_active is not None: if is_active is not None:
detail["active"] = is_active detail["active"] = is_active
if quota_mb is not None: if quota_mb is not None:
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)" detail["quota"] = f"{args.quota_gb} GB (-> {quota_mb} MB stored)"
if args.name is not None: if args.name is not None:
detail["name"] = args.name detail["name"] = args.name
if args.role is not None: if args.role is not None:
@@ -272,7 +287,7 @@ def cmd_user_set(c: SeafileClient, args) -> int:
def cmd_quota(c: SeafileClient, args) -> int: def cmd_quota(c: SeafileClient, args) -> int:
quota_mb = _gb_to_mb(args.gb) quota_mb = _gb_to_mb(args.gb)
detail = {"email": args.email, "quota": f"{quota_mb} MB (= {args.gb} GiB)"} detail = {"email": args.email, "quota": f"{args.gb} GB (-> {quota_mb} MB stored)"}
return _do_write(args, f"set quota for {args.email}", detail, return _do_write(args, f"set quota for {args.email}", detail,
lambda: c.update_user(args.email, quota_mb=quota_mb)) lambda: c.update_user(args.email, quota_mb=quota_mb))
@@ -301,13 +316,31 @@ def cmd_lib_delete(c: SeafileClient, args) -> int:
lambda: c.delete_library(args.repo_id)) lambda: c.delete_library(args.repo_id))
def _share_result_check(result) -> int:
"""Seafile's admin bulk-share returns HTTP 200 with success[]/failed[] arrays;
surface any per-recipient failure instead of reporting a blanket [OK]."""
if isinstance(result, dict):
failed = result.get("failed") or []
if failed:
print("[WARNING] some recipients were NOT shared:")
for f in failed:
who = f.get("email") or f.get("group_id") or f if isinstance(f, dict) else f
emsg = f.get("error_msg") or f.get("error") or "failed" if isinstance(f, dict) else "failed"
print(f" {who}: {emsg}")
ok = result.get("success") or result.get("shared_to") or []
print(f"[PARTIAL] shared to {len(ok)}, failed {len(failed)}")
return 1
return 0
def cmd_share(c: SeafileClient, args) -> int: def cmd_share(c: SeafileClient, args) -> int:
stype = "group" if args.group else "user" stype = "group" if args.group else "user"
detail = {"repo_id": args.repo_id, "share_to": args.to, detail = {"repo_id": args.repo_id, "share_to": args.to,
"permission": args.perm, "type": stype} "permission": args.perm, "type": stype}
return _do_write(args, f"share library {args.repo_id}", detail, return _do_write(args, f"share library {args.repo_id}", detail,
lambda: c.share_library(args.repo_id, args.to, lambda: c.share_library(args.repo_id, args.to,
permission=args.perm, share_type=stype)) permission=args.perm, share_type=stype),
on_result=_share_result_check)
def cmd_unshare(c: SeafileClient, args) -> int: def cmd_unshare(c: SeafileClient, args) -> int:
@@ -343,7 +376,7 @@ def build_parser() -> argparse.ArgumentParser:
ust.add_argument("--role"); ust.add_argument("--password") ust.add_argument("--role"); ust.add_argument("--password")
ust.add_argument("--confirm", action="store_true") ust.add_argument("--confirm", action="store_true")
q = sub.add_parser("quota", help="set a user's storage quota (GiB)") q = sub.add_parser("quota", help="set a user's storage quota (decimal GB)")
q.add_argument("email"); q.add_argument("--gb", type=float, required=True) q.add_argument("email"); q.add_argument("--gb", type=float, required=True)
q.add_argument("--confirm", action="store_true") q.add_argument("--confirm", action="store_true")

View File

@@ -181,34 +181,22 @@ class SeafileClient:
The Seafile admin endpoints read from request.data; the share endpoints The Seafile admin endpoints read from request.data; the share endpoints
use QueryDict.getlist('share_to'), which only works for form-encoded use QueryDict.getlist('share_to'), which only works for form-encoded
bodies (not JSON) -- so all writes go out as bodies (not JSON) -- so all writes go out as form-urlencoded via the
application/x-www-form-urlencoded. `form` values that are lists are shared transport (bc.http_json form=). Any 2xx is success; on error we
expanded (doseq) into repeated fields. surface the server's message.
""" """
url = f"{self.base}{path}" status, parsed = bc.http_json(
data = urllib.parse.urlencode(form or {}, doseq=True).encode() method, f"{self.base}{path}", form=form or {},
req = bc.urllib.request.Request( headers={"Authorization": f"Token {self._tok()}"})
url, data=data, method=method,
headers={"Authorization": f"Token {self._tok()}",
"Content-Type": "application/x-www-form-urlencoded"})
try:
with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp:
status = resp.getcode()
raw = resp.read().decode("utf-8", errors="replace")
except bc.urllib.error.HTTPError as exc:
status = exc.code
raw = exc.read().decode("utf-8", errors="replace")
except bc.urllib.error.URLError as exc:
raise bc.BackupError(f"Seafile {method} {path} failed: {exc}")
if status in (401, 403) and retry_auth: if status in (401, 403) and retry_auth:
self._token = self._authorize() self._token = self._authorize()
return self._write(method, path, form, retry_auth=False) return self._write(method, path, form, retry_auth=False)
try: if not (200 <= status < 300):
parsed = json.loads(raw) if raw.strip() else {} msg = ""
except json.JSONDecodeError: if isinstance(parsed, dict):
parsed = {"_raw": raw[:600]} msg = parsed.get("error_msg") or parsed.get("detail") or ""
if status not in (200, 201): if not msg:
msg = parsed.get("error_msg") or parsed.get("detail") or raw[:400] msg = json.dumps(parsed)[:400] if parsed else ""
raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}", raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}",
status=status) status=status)
return parsed return parsed

9
.uadd.json Normal file
View File

@@ -0,0 +1,9 @@
{
"uid": "zz-claude-jsontest",
"created": true,
"generated_password": "2Ox=YsLpEcE7wt7Um9Oi",
"detail": {
"uid": "zz-claude-jsontest",
"output": "The user \"zz-claude-jsontest\" was created successfully\nDisplay name set to \"JSON Test\""
}
}

View File

@@ -19,6 +19,11 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
<!-- Append entries below this line --> <!-- Append entries below this line -->
2026-07-06 | Howard-Home | owncloud/testing | [friction] ran gated 'versions-cleanup --all-users --confirm' in a smoke test believing it would not execute; it DID (gating worked as designed) and a detached server-side occ versions:cleanup ran ~3min before I killed it. Fix: never pass --confirm to destructive all-users ops in tests; use refusal-path (no --confirm) only [ctx: skill=owncloud host=172.16.3.22 impact=partial-version-history-loss-user-mara]
2026-07-06 | Howard-Home | bash/tmp-path | [friction] wrote git commit -F message to /tmp; blocked by block-tmp-path hook; used repo-relative ./.wsloop-commit.txt [ctx: ref=feedback_tmp_path_windows repo=gururmm]
2026-07-06 | Howard-Home | msp360 | GET /api/Computers/0/1000 -> HTTP 400: {'Message': 'Remote Management API methods are not enabled for your account'} [ctx: command=computers] 2026-07-06 | Howard-Home | msp360 | GET /api/Computers/0/1000 -> HTTP 400: {'Message': 'Remote Management API methods are not enabled for your account'} [ctx: command=computers]
2026-07-06 | Howard-Home | msp360 | unexpected: URL can't contain control characters. '/Program Files/Git/api/Companies' (found at least ' ') [ctx: command=raw] 2026-07-06 | Howard-Home | msp360 | unexpected: URL can't contain control characters. '/Program Files/Git/api/Companies' (found at least ' ') [ctx: command=raw]
@@ -83,7 +88,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
2026-07-04 | GURU-5070 | dataforth/ctonw-v5.1 | CONFIG.SYS collection used 'IF NOT EXIST T:STATUS%MACHINE%NUL MD ...' (the NUL dir-exists test is unreliable on a network/SMB drive) and COPY targets had NO trailing backslash -> MD skipped, COPY created FILES named after each machine instead of per-machine folders, and 'MEM /C > T:STATUS%MACHINE%MEM.TXT' wrote to a nonexistent path -> DOS 'T: write fault Abort/Retry/Fail?' HUNG unattended boot on production DOS test stations. Reverted CTONW to v5.0. Fix: pre-create per-machine dirs server-side on the NAS, use trailing backslash on COPY dir targets, never let a network write error reach the critical-error handler at boot; test on ONE isolated station before any fleet exposure. [ctx: ref=dos-network-drive-copy sev=production-boot-hang] 2026-07-04 | GURU-5070 | dataforth/ctonw-v5.1 | CONFIG.SYS collection used 'IF NOT EXIST T:STATUS%MACHINE%NUL MD ...' (the NUL dir-exists test is unreliable on a network/SMB drive) and COPY targets had NO trailing backslash -> MD skipped, COPY created FILES named after each machine instead of per-machine folders, and 'MEM /C > T:STATUS%MACHINE%MEM.TXT' wrote to a nonexistent path -> DOS 'T: write fault Abort/Retry/Fail?' HUNG unattended boot on production DOS test stations. Reverted CTONW to v5.0. Fix: pre-create per-machine dirs server-side on the NAS, use trailing backslash on COPY dir targets, never let a network write error reach the critical-error handler at boot; test on ONE isolated station before any fleet exposure. [ctx: ref=dos-network-drive-copy sev=production-boot-hang]
2026-07-04 | GURU-5070 | deploy/dos-batch | [friction] reported CRLF via grep -c $' 2026-07-04 | GURU-5070 | deploy/dos-batch | [friction] reported CRLF via grep -c $'
$' which misfired on an LF-only file the Write tool produced; staged an LF-only .BAT to the Dataforth NAS (DOS needs CRLF). Caught by Mike at the station. Fix: verify line endings with 'tr -cd
| wc -c' (must equal LF count) and CRLF-convert Write-tool batch files with 'sed s/$/
/' before deploy. [ctx: ref=projects/dataforth-dos/.gitattributes file=NWTOC.v5.1]
2026-07-04 | GURU-5070 | ps-encoded | encode produced empty output [ctx: src=/dev/fd/63] 2026-07-04 | GURU-5070 | ps-encoded | encode produced empty output [ctx: src=/dev/fd/63]
@@ -236,7 +244,8 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
2026-06-29 | Howard-Home | rmm/powershell | [friction] used $pid as a variable in remote PS script; $PID is a reserved automatic variable (current process id) so the .zip ProgID read was clobbered (showed 16044). Use a non-reserved name e.g. $zipProg [ctx: ref=feedback_windows_quote_stripping-style-PS-gotchas] 2026-06-29 | Howard-Home | rmm/powershell | [friction] used $pid as a variable in remote PS script; $PID is a reserved automatic variable (current process id) so the .zip ProgID read was clobbered (showed 16044). Use a non-reserved name e.g. $zipProg [ctx: ref=feedback_windows_quote_stripping-style-PS-gotchas]
2026-06-29 | Howard-Home | rmm/rednour-legalasst | [correction] assumed LEGALASST was the cloned machine; correct is that CARRIE'S machine was cloned (to host rednourcarrievirt) and LEGALASST is EMMA'S machine (not cloned). Emma's drives X/Y/Z were remapped today to
ednourcarrievirt [ctx: client=rednour host=LEGALASST]
2026-06-29 | Howard-Home | rmm-auth/tailscale | [friction] RMM+coord unreachable (http=000); tailscaled service RUNNING but backend stuck in NoState after restart -> 172.16.3.30 unping-able from HOWARD-HOME [ctx: ref=remote-diag fix=tailscale-relogin] 2026-06-29 | Howard-Home | rmm-auth/tailscale | [friction] RMM+coord unreachable (http=000); tailscaled service RUNNING but backend stuck in NoState after restart -> 172.16.3.30 unping-able from HOWARD-HOME [ctx: ref=remote-diag fix=tailscale-relogin]