sync: auto-sync from HOWARD-HOME at 2026-06-21 18:54:05
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-21 18:54:05
This commit is contained in:
247
.claude/skills/screenconnect/scripts/sc.py
Normal file
247
.claude/skills/screenconnect/scripts/sc.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for the screenconnect skill - ConnectWise ScreenConnect (Control) API.
|
||||
|
||||
Read-only subcommands run freely. State-changing subcommands (send-command,
|
||||
send-message, set-properties) refuse to run without --confirm.
|
||||
|
||||
Usage:
|
||||
python sc.py status # auth check + instance info
|
||||
python sc.py sessions [--name NAME] [--json]
|
||||
python sc.py session <sessionID> # detail (pending extension unlock)
|
||||
python sc.py send-command --session <id> --command "..." --confirm
|
||||
python sc.py send-message --session <id> --message "..." --confirm
|
||||
python sc.py set-properties --session <id> --json '[null,"Site","Tag"]' --confirm
|
||||
python sc.py raw --method GetSessionsByName --body '{"sessionName":""}' [--get]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from sc_client import (
|
||||
ScreenConnectClient,
|
||||
ScreenConnectError,
|
||||
SC_BASE_URL,
|
||||
CUSTOM_PROPERTIES,
|
||||
)
|
||||
|
||||
# --- errorlog (skill rule): log GENUINE failures only, never expected conditions ---
|
||||
_EXPECTED_ERROR_MARKERS = (
|
||||
"web method does not exist", # method not unlocked on this instance yet
|
||||
"429",
|
||||
"too many requests",
|
||||
)
|
||||
|
||||
|
||||
def _is_expected_error(msg: str) -> bool:
|
||||
m = (msg or "").lower()
|
||||
return any(marker in m for marker in _EXPECTED_ERROR_MARKERS)
|
||||
|
||||
|
||||
def _should_log_error(command: str, msg: str) -> bool:
|
||||
if os.environ.get("SC_SUPPRESS_ERRORLOG"):
|
||||
return False
|
||||
if command == "raw":
|
||||
return False
|
||||
return not _is_expected_error(msg)
|
||||
|
||||
|
||||
def _log_skill_error(skill, msg, context=""):
|
||||
"""Soft-fail append to errorlog.md via the canonical helper (never throws)."""
|
||||
try:
|
||||
root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
|
||||
)
|
||||
h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
|
||||
if not os.path.exists(h):
|
||||
return
|
||||
a = ["bash", h, skill, msg]
|
||||
if context:
|
||||
a += ["--context", context]
|
||||
subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit(obj, as_json: bool) -> None:
|
||||
print(json.dumps(obj, indent=2, default=str))
|
||||
|
||||
|
||||
def _print_sessions(data) -> None:
|
||||
items = data if isinstance(data, list) else [data]
|
||||
print(f"Sessions: {len(items)}")
|
||||
for s in items:
|
||||
if not isinstance(s, dict):
|
||||
print(f" {s}")
|
||||
continue
|
||||
cp = s.get("CustomProperties") or s.get("CustomPropertyValues") or []
|
||||
cps = ",".join(str(x) for x in cp) if isinstance(cp, list) else str(cp)
|
||||
print(
|
||||
f" {s.get('SessionID','?')} type={s.get('SessionType','?')} "
|
||||
f"name={s.get('Name','') or '-'!r} host={s.get('Host','') or '-'} "
|
||||
f"guest={s.get('GuestInfoName', s.get('GuestMachineName',''))} [{cps}]"
|
||||
)
|
||||
|
||||
|
||||
# --- gating ---
|
||||
def _gated(action_desc: str, confirm: bool) -> bool:
|
||||
if not confirm:
|
||||
print("[WARNING] Refusing state-changing action without --confirm.")
|
||||
print(f"[INFO] Would: {action_desc}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- handlers ---
|
||||
def cmd_status(client, args):
|
||||
# Auth check via the one verified method; report instance + custom-property map.
|
||||
sessions = client.get_sessions_by_name("")
|
||||
n = len(sessions) if isinstance(sessions, list) else 0
|
||||
print(f"[OK] Authenticated to {SC_BASE_URL}")
|
||||
print(f" extension: {client.extension_guid}")
|
||||
print(f" custom properties: {CUSTOM_PROPERTIES}")
|
||||
print(f" GetSessionsByName('') returned {n} session(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_sessions(client, args):
|
||||
_print_sessions(client.get_sessions_by_name(args.name)) if not args.json else _emit(
|
||||
client.get_sessions_by_name(args.name), True
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session(client, args):
|
||||
_emit(client.get_session_details(args.session_id), True)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_send_command(client, args):
|
||||
if not _gated(f"run command on session {args.session} : {args.run_command!r}", args.confirm):
|
||||
return 3
|
||||
_emit(client.send_command_to_session(args.session, args.run_command), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_send_message(client, args):
|
||||
if not _gated(f"message session {args.session}", args.confirm):
|
||||
return 3
|
||||
_emit(client.send_message_to_session(args.session, args.message), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set_properties(client, args):
|
||||
try:
|
||||
props = json.loads(args.json_props)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"[ERROR] --json is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
if not isinstance(props, list):
|
||||
print("[ERROR] --json must be a JSON array (CP1=Company, CP2=Site, CP3=Tag).",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not _gated(f"set custom properties on session {args.session}", args.confirm):
|
||||
return 3
|
||||
_emit(client.update_session_custom_properties(args.session, props), args.json)
|
||||
return 0
|
||||
|
||||
|
||||
DESTRUCTIVE_RAW_PATTERNS = ("sendcommand", "updatesession", "create", "delete",
|
||||
"end", "remove", "transfer", "install", "uninstall")
|
||||
|
||||
|
||||
def cmd_raw(client, args):
|
||||
m = args.method.lower()
|
||||
if any(p in m for p in DESTRUCTIVE_RAW_PATTERNS) and not args.confirm:
|
||||
print(f"[WARNING] '{args.method}' looks state-changing; refusing without --confirm.",
|
||||
file=sys.stderr)
|
||||
return 3
|
||||
body = None
|
||||
if args.body:
|
||||
try:
|
||||
body = json.loads(args.body)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(f"[ERROR] --body is not valid JSON: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
result = client.raw(args.method, body, http_method="GET" if args.get else "POST")
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="sc.py",
|
||||
description="ConnectWise ScreenConnect API CLI (ACG).")
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--json", action="store_true", help="Emit raw JSON.")
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("status", help="Auth check + instance info.", parents=[common])
|
||||
|
||||
sp = sub.add_parser("sessions", help="List sessions by Name (verified).", parents=[common])
|
||||
sp.add_argument("--name", default="", help="Session Name filter (blank = unattended).")
|
||||
|
||||
sp = sub.add_parser("session", help="Session detail (pending unlock).", parents=[common])
|
||||
sp.add_argument("session_id")
|
||||
|
||||
sp = sub.add_parser("send-command", help="Run a backstage command (gated; pending unlock).",
|
||||
parents=[common])
|
||||
sp.add_argument("--session", required=True)
|
||||
sp.add_argument("--command", dest="run_command", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("send-message", help="Send a chat message (gated; pending unlock).",
|
||||
parents=[common])
|
||||
sp.add_argument("--session", required=True)
|
||||
sp.add_argument("--message", required=True)
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("set-properties",
|
||||
help="Set CP1/CP2/CP3 (gated; pending unlock).", parents=[common])
|
||||
sp.add_argument("--session", required=True)
|
||||
sp.add_argument("--props-json", dest="json_props", required=True,
|
||||
help='JSON array, e.g. ["Company","Site","Tag"]')
|
||||
sp.add_argument("--confirm", action="store_true")
|
||||
|
||||
sp = sub.add_parser("raw", help="Call any method directly (power use / probing).",
|
||||
parents=[common])
|
||||
sp.add_argument("--method", required=True)
|
||||
sp.add_argument("--body", help="JSON body.")
|
||||
sp.add_argument("--get", action="store_true", help="Use GET instead of POST.")
|
||||
sp.add_argument("--confirm", action="store_true",
|
||||
help="Required for state-changing method names.")
|
||||
return p
|
||||
|
||||
|
||||
HANDLERS = {
|
||||
"status": cmd_status,
|
||||
"sessions": cmd_sessions,
|
||||
"session": cmd_session,
|
||||
"send-command": cmd_send_command,
|
||||
"send-message": cmd_send_message,
|
||||
"set-properties": cmd_set_properties,
|
||||
"raw": cmd_raw,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
handler = HANDLERS[args.command]
|
||||
try:
|
||||
client = ScreenConnectClient()
|
||||
rc = handler(client, args)
|
||||
return rc if isinstance(rc, int) else 0
|
||||
except ScreenConnectError as exc:
|
||||
print(f"[ERROR] {exc}", file=sys.stderr)
|
||||
cmd = getattr(args, "command", "?")
|
||||
if _should_log_error(cmd, str(exc)):
|
||||
_log_skill_error("screenconnect", f"{exc}", context=f"cmd={cmd}")
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -214,25 +214,21 @@ class ScreenConnectClient:
|
||||
return self.call("GetSessionDetailsBySessionID", {"sessionID": session_id})
|
||||
|
||||
def send_command_to_session(self, session_id: str, command: str) -> Any:
|
||||
"""SendCommandToSession — run a backstage command on a guest. PENDING UNLOCK.
|
||||
STATE-CHANGING (gate behind --confirm at the call site)."""
|
||||
return self.call(
|
||||
"SendCommandToSession", {"sessionID": session_id, "command": command}
|
||||
)
|
||||
"""SendCommandToSession — run a backstage command on a guest. EXISTS on this
|
||||
instance. POST body is a POSITIONAL ARRAY [sessionID, command]
|
||||
(e.g. ["<uuid>", "ipconfig"]). STATE-CHANGING (gate behind --confirm)."""
|
||||
return self.call("SendCommandToSession", [session_id, command])
|
||||
|
||||
def send_message_to_session(self, session_id: str, message: str) -> Any:
|
||||
"""SendMessageToSession — send a chat message to a guest. PENDING UNLOCK."""
|
||||
return self.call(
|
||||
"SendMessageToSession", {"sessionID": session_id, "message": message}
|
||||
)
|
||||
"""SendMessageToSession — send a chat message to a guest. EXISTS.
|
||||
POST body positional array [sessionID, message]. STATE-CHANGING."""
|
||||
return self.call("SendMessageToSession", [session_id, message])
|
||||
|
||||
def update_session_custom_properties(self, session_id: str, properties: list) -> Any:
|
||||
"""UpdateSessionCustomProperties (CP1=Company, CP2=Site, CP3=Tag). PENDING UNLOCK.
|
||||
STATE-CHANGING."""
|
||||
return self.call(
|
||||
"UpdateSessionCustomProperties",
|
||||
{"sessionID": session_id, "customProperties": properties},
|
||||
)
|
||||
"""UpdateSessionCustomProperties (CP1=Company, CP2=Site, CP3=Tag). EXISTS.
|
||||
POST body positional array [sessionID, [cp1, cp2, cp3, ...]]. STATE-CHANGING.
|
||||
(Confirm exact arg order against a safe test session before relying on it.)"""
|
||||
return self.call("UpdateSessionCustomProperties", [session_id, properties])
|
||||
|
||||
def raw(self, method: str, body: Any = None, http_method: str = "POST") -> Any:
|
||||
"""Call any RESTful API Manager method directly (power use / probing)."""
|
||||
|
||||
@@ -17,6 +17,8 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
||||
|
||||
<!-- Append entries below this line -->
|
||||
|
||||
2026-06-22 | Howard-Home | deploy/cpanel | [friction] cPanel deploy served STALE files post-upload (opcache) - page showed only top buttons + api.php 403; fix: opcache_reset via one-off _oc.php hit through external-IP origin path (127.0.0.1 vhost 404s) then browser hard-reload
|
||||
|
||||
2026-06-22 | Howard-Home | ssh/windows | [friction] native Windows OpenSSH (System32 ssh.exe) SSH_ASKPASS fails 'CreateProcessW error:193' on a .sh askpass; for non-interactive password auth use MSYS bare 'ssh' (Git-for-Windows) which execs the shell askpass (as pfsense-ssh.sh does)
|
||||
|
||||
2026-06-22 | Howard-Home | ssh/php-cli | [friction] inline 'ssh root@host "php -r ..."' mangled (printed PHP usage) — nested bash->ssh->single-quote escaping strips the -r script; ship a base64'd .php file and run 'php file.php' instead [ctx: ref=feedback_windows_quote_stripping]
|
||||
|
||||
@@ -109,3 +109,45 @@ php _deploytest.php export <id> client # posture/recommendations, 0 ACG-servic
|
||||
- Live server backups: `*.bak-20260621-181744` in the docroot (rollback if needed).
|
||||
- Export endpoint: `api.php?action=export&id=<id>&view=internal|client` (origin requires the CF Access email header).
|
||||
- Companion logs: `2026-06-21-howard-security-assessment-scoring.md`, `...-unifi-pfsense-control-verbs.md`, `...-gururmm-bug-018-019.md`.
|
||||
|
||||
---
|
||||
|
||||
## Update: 18:53 PT — site was broken in browser (stale opcache); root-caused + fixed; CONFIRMED loading
|
||||
|
||||
After the deploy, Howard reported the live page "mostly broken — only the Export and Assessments
|
||||
buttons at the top." Diagnosed and resolved.
|
||||
|
||||
### What was wrong (two compounding issues)
|
||||
1. **api.php allow-list bug** (already fixed earlier this session): single `strcasecmp($email, ALLOWED_EMAIL)`
|
||||
vs the whole comma string → 403 for every API call. Confirmed live via the access log (`POST /api.php?action=save 403` repeatedly).
|
||||
2. **Stale PHP OPcache** — the server kept executing the OLD `index.php` (rendered output 14637 bytes) and OLD
|
||||
`api.php` even though the new files were on disk (md5 matched local). So the new wizard never rendered (boot()
|
||||
ran the old code / the old api 403'd) and the fix wasn't live.
|
||||
|
||||
### Diagnosis path
|
||||
- Verified deployed files = local (identical md5), perms 644, `.htaccess` benign, questions.json valid on server,
|
||||
no PHP error log → not a crash. Ran the wizard JS in node with DOM/fetch stubs → boot() completed fine (so not
|
||||
a code bug). The **Apache SSL access log** (`/etc/apache2/logs/domlogs/security.azcomputerguru.com-ssl_log`) was
|
||||
the smoking gun: `GET / 200 14637` (old size; new index.php renders ~27.9k) + many `api.php ... 403`.
|
||||
- SAPI = Apache (httpd) + opcache On. Origin-direct curl to 127.0.0.1 hits a default vhost that 404s — the app is
|
||||
only reachable via the EXTERNAL IP path Cloudflare uses.
|
||||
|
||||
### Fix
|
||||
- Dropped a one-off `_oc.php` (`opcache_reset()`) in the docroot and ran it through the real path:
|
||||
`curl -sk --resolve security.azcomputerguru.com:443:72.194.62.5 -H 'Cf-Access-Authenticated-User-Email: mike@azcomputerguru.com' https://.../_oc.php` → `opcache_reset: OK` (validate_timestamps=1, but the cache hadn't revalidated; reset forced it).
|
||||
- Verified: `GET /` now 200 **27885 bytes**, served HTML contains `computeScores`/`gradeFor`/"Posture & findings";
|
||||
`api.php?action=list` returns JSON (no 403). Removed `_oc.php` + local scratch (`_jsrun.cjs`, `_deploytest.php`, `_smoke.php`).
|
||||
- Howard confirmed: **"it is loading now."**
|
||||
|
||||
### Durable fixes
|
||||
- `DEPLOY.md` updated (`31bc786`): "FLUSH OPCACHE after updating" (the external-IP `_oc.php` recipe) + the
|
||||
comma-separated `ALLOWED_EMAIL` gotcha. claudetools pin advanced (`5299f3d`). Logged --friction (deploy/cpanel: stale opcache).
|
||||
|
||||
### Key gotchas for next deploy
|
||||
- After uploading PHP changes to this cPanel host, opcache can serve stale bytecode → flush it (recipe in DEPLOY.md).
|
||||
- Origin-direct (127.0.0.1) 404s the vhost; test/flush via `--resolve ...:443:72.194.62.5` (external IP).
|
||||
- Coord (Mike, GURU-5070): BUG-018 (cea87d4) + Event Log Watch UI merged to gururmm main + deploying; I'm cleared to self-merge gururmm going forward.
|
||||
|
||||
### Net state
|
||||
security.azcomputerguru.com is LIVE and working (new scoring wizard + larger UI + dual export + fixed backend),
|
||||
verified server-side and confirmed loading in Howard's browser. Remaining: #1 RMM prefill (deferred, infra), FR-1 portal (deferred, auth decision).
|
||||
|
||||
Reference in New Issue
Block a user