sync: auto-sync from HOWARD-HOME at 2026-06-21 18:27:49
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-21 18:27:49
This commit is contained in:
255
.claude/skills/screenconnect/scripts/sc_client.py
Normal file
255
.claude/skills/screenconnect/scripts/sc_client.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ConnectWise ScreenConnect (Control) API client for the screenconnect skill.
|
||||||
|
|
||||||
|
Talks to the ACG ScreenConnect instance via the RESTful API Manager extension.
|
||||||
|
Standalone (no third-party hard dependency): prefers httpx, falls back to stdlib
|
||||||
|
urllib.
|
||||||
|
|
||||||
|
Auth (VERIFIED 2026-06-02 by Howard, re-verified 2026-06-21): HTTP header
|
||||||
|
CTRLAuthHeader: <raw api_secret> (NO "Basic" prefix; Basic auth 401s)
|
||||||
|
Origin: https://computerguru.screenconnect.com
|
||||||
|
Endpoints live under the RESTful API Manager extension:
|
||||||
|
POST <base>/App_Extensions/<guid>/Service.ashx/<MethodName> body = JSON
|
||||||
|
GET is used for read-only methods, POST for state-changing ones; Content-Type is
|
||||||
|
application/json and the body is the method's parameters (object or array).
|
||||||
|
|
||||||
|
Credentials: never hardcoded. api_secret loaded at runtime from the SOPS vault,
|
||||||
|
or the SCREENCONNECT_API_SECRET env var (testing override).
|
||||||
|
|
||||||
|
NOTE (instance state, 2026-06-21): the installed RESTful API Manager extension is
|
||||||
|
LIMITED — only `GetSessionsByName` exists; other methods 500 "Web method does not
|
||||||
|
exist". Full control (SendCommandToSession, GetSessions, UpdateSessionCustom-
|
||||||
|
Properties, ...) requires updating the extension on the instance. The client is
|
||||||
|
built to expose those methods as soon as the extension is unlocked; `raw()` probes
|
||||||
|
arbitrary methods in the meantime.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx # type: ignore
|
||||||
|
|
||||||
|
_HAS_HTTPX = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_HAS_HTTPX = False
|
||||||
|
|
||||||
|
ERROR_BODY_MAX_CHARS = 500
|
||||||
|
|
||||||
|
# ACG instance config (non-secret; matches the vault entry). Env-overridable.
|
||||||
|
SC_BASE_URL = os.environ.get(
|
||||||
|
"SCREENCONNECT_BASE_URL", "https://computerguru.screenconnect.com"
|
||||||
|
)
|
||||||
|
SC_EXTENSION_GUID = os.environ.get(
|
||||||
|
"SCREENCONNECT_EXTENSION_GUID", "2d558935-686a-4bd0-9991-07539f5fe749"
|
||||||
|
)
|
||||||
|
SC_TIMEOUT_SECONDS = 60.0
|
||||||
|
SC_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||||
|
|
||||||
|
VAULT_ENTRY = "msp-tools/screenconnect.sops.yaml"
|
||||||
|
VAULT_FIELD = "credentials.api_secret"
|
||||||
|
|
||||||
|
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Custom-property mapping on this instance (from the vault notes).
|
||||||
|
CUSTOM_PROPERTIES = {"CP1": "Company", "CP2": "Site", "CP3": "Tag"}
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenConnectError(RuntimeError):
|
||||||
|
"""Raised for transport or API errors."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_claudetools_root() -> Path:
|
||||||
|
derived_root = SKILL_DIR.parent.parent.parent # .claude/skills/screenconnect -> root
|
||||||
|
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||||
|
if env_root:
|
||||||
|
return Path(env_root)
|
||||||
|
identity_path = derived_root / ".claude" / "identity.json"
|
||||||
|
if identity_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||||
|
root = data.get("claudetools_root")
|
||||||
|
if root:
|
||||||
|
return Path(root)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return derived_root
|
||||||
|
|
||||||
|
|
||||||
|
def load_api_secret() -> str:
|
||||||
|
"""Load the ScreenConnect API secret: env override, then the SOPS vault."""
|
||||||
|
env_secret = os.environ.get("SCREENCONNECT_API_SECRET")
|
||||||
|
if env_secret:
|
||||||
|
return env_secret.strip()
|
||||||
|
|
||||||
|
root = _resolve_claudetools_root()
|
||||||
|
vault_script = root / ".claude" / "scripts" / "vault.sh"
|
||||||
|
if not vault_script.exists():
|
||||||
|
raise ScreenConnectError(
|
||||||
|
f"Cannot load API secret: vault wrapper not found at {vault_script} "
|
||||||
|
"and SCREENCONNECT_API_SECRET is not set."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["bash", str(vault_script), "get-field", VAULT_ENTRY, VAULT_FIELD],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ScreenConnectError(
|
||||||
|
"Cannot load API secret: 'bash' not found on PATH."
|
||||||
|
) from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise ScreenConnectError("Cannot load API secret: vault call timed out.") from exc
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise ScreenConnectError(
|
||||||
|
f"Cannot load API secret from vault (exit {completed.returncode}): "
|
||||||
|
f"{completed.stderr.strip()}"
|
||||||
|
)
|
||||||
|
secret = completed.stdout.strip()
|
||||||
|
if not secret:
|
||||||
|
raise ScreenConnectError("Vault returned an empty API secret.")
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenConnectClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_secret: Optional[str] = None,
|
||||||
|
base_url: str = SC_BASE_URL,
|
||||||
|
extension_guid: str = SC_EXTENSION_GUID,
|
||||||
|
timeout: float = SC_TIMEOUT_SECONDS,
|
||||||
|
connect_timeout: float = SC_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.extension_guid = extension_guid
|
||||||
|
self._api_secret = api_secret
|
||||||
|
self.timeout = timeout
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_secret(self) -> str:
|
||||||
|
if not self._api_secret:
|
||||||
|
self._api_secret = load_api_secret()
|
||||||
|
return self._api_secret
|
||||||
|
|
||||||
|
def _service_url(self, method: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.base_url}/App_Extensions/{self.extension_guid}"
|
||||||
|
f"/Service.ashx/{method}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"CTRLAuthHeader": self.api_secret,
|
||||||
|
"Origin": self.base_url,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def call(self, method: str, body: Any = None, http_method: str = "POST") -> Any:
|
||||||
|
"""Call a RESTful API Manager method. Returns parsed JSON (or raw text).
|
||||||
|
|
||||||
|
`body` is the method's parameters (dict/list); serialized as JSON.
|
||||||
|
Raises ScreenConnectError on a non-2xx response.
|
||||||
|
"""
|
||||||
|
url = self._service_url(method)
|
||||||
|
data = json.dumps(body if body is not None else {}).encode("utf-8")
|
||||||
|
status, text = self._request(url, data, http_method)
|
||||||
|
if status >= 300:
|
||||||
|
snippet = (text or "")[:ERROR_BODY_MAX_CHARS]
|
||||||
|
raise ScreenConnectError(
|
||||||
|
f"ScreenConnect API error [{method}]: HTTP {status}: {snippet}"
|
||||||
|
)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _request(self, url: str, data: bytes, http_method: str):
|
||||||
|
headers = self._headers()
|
||||||
|
if _HAS_HTTPX:
|
||||||
|
try:
|
||||||
|
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
resp = client.request(http_method, url, content=data, headers=headers)
|
||||||
|
return resp.status_code, resp.text
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ScreenConnectError(f"ScreenConnect request failed: {exc}") from exc
|
||||||
|
# stdlib fallback
|
||||||
|
req = urllib.request.Request(url, data=data, method=http_method, headers=headers)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
return resp.status, resp.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read().decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ScreenConnectError(f"ScreenConnect request failed: {exc}") from exc
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# VERIFIED methods (work on the current instance)
|
||||||
|
# ======================================================================
|
||||||
|
def get_sessions_by_name(self, session_name: str = "") -> Any:
|
||||||
|
"""List sessions whose Name matches `session_name` (RESTful API Manager
|
||||||
|
GetSessionsByName). VERIFIED LIVE. Empty string returns sessions with a
|
||||||
|
blank Name (the unattended access agents on this instance)."""
|
||||||
|
return self.call("GetSessionsByName", {"sessionName": session_name})
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Methods pending the extension unlock (currently 500 "web method does not
|
||||||
|
# exist"). Exposed here so the CLI is ready; verify each once unlocked.
|
||||||
|
# Shapes are best-effort from the RESTful API Manager docs and MUST be
|
||||||
|
# confirmed by live probing before relying on them.
|
||||||
|
# ======================================================================
|
||||||
|
def get_session_details(self, session_id: str) -> Any:
|
||||||
|
"""GetSessionDetailsBySessionID — full detail for one session. PENDING UNLOCK."""
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
|
def raw(self, method: str, body: Any = None, http_method: str = "POST") -> Any:
|
||||||
|
"""Call any RESTful API Manager method directly (power use / probing)."""
|
||||||
|
return self.call(method, body, http_method=http_method)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Minimal self-check: load secret (no network call)."""
|
||||||
|
try:
|
||||||
|
client = ScreenConnectClient()
|
||||||
|
_ = client.api_secret
|
||||||
|
print("[OK] API secret loaded; transport =", "httpx" if _HAS_HTTPX else "urllib")
|
||||||
|
return 0
|
||||||
|
except ScreenConnectError as exc:
|
||||||
|
print(f"[ERROR] {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -17,6 +17,10 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
|||||||
|
|
||||||
<!-- Append entries below this line -->
|
<!-- Append entries below this line -->
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
2026-06-22 | GURU-5070 | coord/purge-bash | [friction] jq-on-Windows emits CRLF: message IDs fed to a curl DELETE loop had trailing CR -> all 208 DELETEs returned HTTP 000 (broken URL). Fixed with tr -d CR + read trim. Repeat of documented gotcha. [ctx: ref=feedback_jq_crlf_windows]
|
2026-06-22 | GURU-5070 | coord/purge-bash | [friction] jq-on-Windows emits CRLF: message IDs fed to a curl DELETE loop had trailing CR -> all 208 DELETEs returned HTTP 000 (broken URL). Fixed with tr -d CR + read trim. Repeat of documented gotcha. [ctx: ref=feedback_jq_crlf_windows]
|
||||||
|
|
||||||
2026-06-22 | GURU-5070 | coord/gururmm-merge-authority | [correction] assumed GuruRMM merges/deploys are Mike-only (held BUG-018 for Mike's go); correct is Howard can handle merges himself
|
2026-06-22 | GURU-5070 | coord/gururmm-merge-authority | [correction] assumed GuruRMM merges/deploys are Mike-only (held BUG-018 for Mike's go); correct is Howard can handle merges himself
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Session — security.azcomputerguru.com: live deploy + smoke test (found/fixed prod auth bug)
|
||||||
|
|
||||||
|
## User
|
||||||
|
- **User:** Howard Enos (howard)
|
||||||
|
- **Machine:** Howard-Home
|
||||||
|
- **Role:** tech
|
||||||
|
|
||||||
|
## Session Summary
|
||||||
|
|
||||||
|
Continued the security-assessment work (same HOWARD-HOME session; see the two companion logs from
|
||||||
|
today). Howard requested: make UI elements slightly larger, test every field for usable info, and a
|
||||||
|
big new multi-tenant client portal + ACG sales view. Did the unambiguous pieces, deferred the portal
|
||||||
|
to a feature request, then (on Howard's "b" greenlight) deployed to the live IX host and functionally
|
||||||
|
tested — which surfaced a production-breaking bug.
|
||||||
|
|
||||||
|
Bumped the wizard UI sizing ~10-12% (text, inputs, spacing, wider sheet, bigger buttons) and committed
|
||||||
|
it. Built `app/fieldcheck.cjs` and audited every questions.json field: 59 fields / 25 scorable, all 25
|
||||||
|
emit a usable finding on worst-case answers, no orphan/missing score keys, all findings complete, all 13
|
||||||
|
requiredControls resolve — every field yields usable info. Captured the portal vision as FR-1 in
|
||||||
|
`FEATURES.md` (personas, no-auto-sync guardrail, quote→active workflow, 3 auth options) and asked the
|
||||||
|
foundational auth decision; Howard chose to defer it to a feature request and focus on testing. Added an
|
||||||
|
executive summary + top-3 prioritized actions + ACG footer to the export (client = recommendations,
|
||||||
|
internal = + ACG service).
|
||||||
|
|
||||||
|
Deployed to IX (172.16.3.10, cPanel account `azcomputerguru`, docroot
|
||||||
|
`/home/azcomputerguru/public_html/security`, PHP 8.1.34) over SSH: backed up the live files, uploaded to
|
||||||
|
`.new`, lint-gated (php -l + JSON validate), then atomic-swapped into place. `config.php` (live secrets)
|
||||||
|
was never touched. The public URL was already live behind Cloudflare Access (a public hit returns CF
|
||||||
|
Access 403), so this was an update, not first-time setup.
|
||||||
|
|
||||||
|
Smoke-testing on the server found a **production-breaking bug**: `api.php`'s allow-list check did a
|
||||||
|
single `strcasecmp($email, ALLOWED_EMAIL)` against the WHOLE comma-separated list, so once a 2nd address
|
||||||
|
(howard@) was added on Jun 19, every API call (lookup/save/load/list/export) returned `forbidden` — the
|
||||||
|
live backend had been unusable. Fixed it to split + membership-check (matching index.php), redeployed,
|
||||||
|
and re-verified: `action=list` reads the DB; internal export renders posture/exec-summary/findings/ACG-
|
||||||
|
service/REQUIRED/captured-intake; client export renders posture+recommendations with zero upsell leaks
|
||||||
|
and no raw intake. Inserted a weak-answer test row, exported both views, then deleted it — prod DB back
|
||||||
|
to its 2 real rows. Committed everything to submodule `main` and advanced the claudetools pin so git
|
||||||
|
matches the live server.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
- **Deploy via root SSH + base64 file upload, lint-gated atomic swap.** Backup → upload `.new` →
|
||||||
|
`php -l`/JSON-validate → `mv` into place. Never overwrite `config.php`. Safe for a live site.
|
||||||
|
- **Functional-test via PHP CLI + a shipped harness, not inline `php -r`.** Inline `ssh "php -r '...'"`
|
||||||
|
mangled through nested quoting (printed PHP usage); a base64'd `.php` harness that includes api.php and
|
||||||
|
drives `$_GET[action]` is reliable. Bypasses the Apache/Cloudflare vhost routing (origin-direct curl
|
||||||
|
404'd — a vhost/SNI artifact, not a broken deploy).
|
||||||
|
- **Test row written to the tool's own MySQL then deleted** — acceptable for a smoke test (its own DB,
|
||||||
|
not Syncro/RMM), clearly tagged `ZZ-SMOKE-DELETE`, cleaned up.
|
||||||
|
- **Portal deferred to FR-1** (Howard's call) rather than building a multi-tenant auth model speculatively.
|
||||||
|
- **MSYS `ssh` for password+askpass**, not native Windows OpenSSH (which fails the .sh askpass) — matches
|
||||||
|
the working pfsense-ssh.sh pattern.
|
||||||
|
|
||||||
|
## Problems Encountered
|
||||||
|
- **PROD BUG — api.php 403 for everyone** (single strcasecmp vs comma list). Found via live smoke test;
|
||||||
|
fixed + redeployed + verified. This is why "test every function" mattered.
|
||||||
|
- **Native Windows OpenSSH askpass failed** `CreateProcessW error:193` (can't exec a .sh askpass) →
|
||||||
|
used MSYS bare `ssh`. Logged --friction.
|
||||||
|
- **Inline `php -r` over ssh mangled** (nested quote stripping) → shipped a base64'd .php harness.
|
||||||
|
Logged --friction (ref `feedback_windows_quote_stripping`).
|
||||||
|
- **Origin-direct curl 404'd** (vhost/SNI; CF proxies the real path) → tested via PHP CLI instead.
|
||||||
|
- **Submodule reset to old scaffold** earlier (stale gitlink + a concurrent submodule-update) → restored
|
||||||
|
to origin/main and advanced the claudetools pin to stop the churn.
|
||||||
|
|
||||||
|
## Configuration Changes
|
||||||
|
security-assessment submodule (all on `main`, pushed; claudetools pin advanced each time):
|
||||||
|
- `app/index.php` — UI sizing bump (`66eb7cb`).
|
||||||
|
- `app/api.php` — export exec-summary/top-3/footer (`3e3a9ab`); **allow-list auth fix** (`f246091`).
|
||||||
|
- `FEATURES.md` (new) — FR-1 portal request; `app/fieldcheck.cjs` (new) — field-audit dev tool (`3a2301b`).
|
||||||
|
- Local scratch `app/_deploytest.php` + `app/_smoke.php` created for testing, removed after (not committed).
|
||||||
|
Live IX server: `index.php`, `api.php`, `questions.json` updated in the docroot (backups `.bak-20260621-181744`); `config.php` untouched.
|
||||||
|
ClaudeTools: this session log + `errorlog.md` (2 friction entries).
|
||||||
|
|
||||||
|
## Credentials & Secrets
|
||||||
|
None created/discovered. Used existing vault `infrastructure/ix-server` (root SSH 172.16.3.10:22, password
|
||||||
|
field `credentials.password`; also a WHM API token `credentials.whm-api-token` = full-access root). DB creds
|
||||||
|
live in the server's `config.php` (vault `msp-tools/security-assessment-db`). No secrets printed/committed.
|
||||||
|
|
||||||
|
## Infrastructure & Servers
|
||||||
|
- IX server 172.16.3.10 (ext 72.194.62.5), Rocky Linux WHM/cPanel, PHP 8.1.34 (ea-php81), root SSH :22.
|
||||||
|
- Site docroot: `/home/azcomputerguru/public_html/security` (cPanel acct `azcomputerguru`, sub of azcomputerguru.com, vhost 172.16.3.10:80/:443).
|
||||||
|
- security.azcomputerguru.com behind Cloudflare Access (Zero Trust app `8ce5f31c-...`; allow mike@ + howard@). Origin answers via CF; origin-direct curl needs `--resolve`/Host and still 404'd (CF-proxied path is the working one).
|
||||||
|
- Live DB (acgsec_assess): 2 real rows (id 1 Darrell Delphen, id 2 empty) after test cleanup.
|
||||||
|
|
||||||
|
## Commands & Outputs
|
||||||
|
```
|
||||||
|
# find docroot
|
||||||
|
ssh root@172.16.3.10 'grep -i security.azcomputerguru.com /etc/userdatadomains'
|
||||||
|
# -> azcomputerguru==root==sub==azcomputerguru.com==/home/azcomputerguru/public_html/security==...ea-php81
|
||||||
|
# deploy pattern: backup -> base64 upload to .new -> php -l -> chown user -> mv (atomic)
|
||||||
|
base64 local.php | ssh root@ix "base64 -d > docroot/file.new"
|
||||||
|
# functional test (bypasses Apache/CF): shipped harness includes api.php
|
||||||
|
php _deploytest.php list # {"items":[{id:2...},{id:1,...}]} (after the auth fix)
|
||||||
|
php _deploytest.php export <id> internal # posture/exec/findings/ACG service/Captured intake
|
||||||
|
php _deploytest.php export <id> client # posture/recommendations, 0 ACG-service, no Captured intake
|
||||||
|
# BUG before fix: php _deploytest.php list -> {"error":"forbidden"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pending / Incomplete Tasks
|
||||||
|
- **Live browser UI click-through** (through Cloudflare Access) — the only thing not tested; needs a human
|
||||||
|
logged into https://security.azcomputerguru.com (look up a client, answer fields, watch posture, Export
|
||||||
|
both views). Backend fully verified server-side.
|
||||||
|
- **#1 GuruRMM endpoint prefill** — deferred (infra: no Syncro→RMM mapping, no reachable RMM API from IX).
|
||||||
|
- **FR-1 multi-tenant portal** — filed in FEATURES.md, awaiting the auth decision when Howard wants it.
|
||||||
|
|
||||||
|
## Reference Information
|
||||||
|
- Submodule `main` HEAD `f246091` (claudetools pin `27c1d97` at time of fix).
|
||||||
|
- Commits this turn: `66eb7cb` sizing, `3a2301b` FR-1+fieldcheck, `3e3a9ab` export exec-summary, `f246091` api auth fix.
|
||||||
|
- 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`.
|
||||||
Reference in New Issue
Block a user