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:
@@ -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` |
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
73
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
73
.claude/skills/owncloud/scripts/dry_test.sh
Normal 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 ]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
9
.uadd.json
Normal 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\""
|
||||||
|
}
|
||||||
|
}
|
||||||
13
errorlog.md
13
errorlog.md
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user