Compare commits

45 Commits

Author SHA1 Message Date
Claude 18783dc137 Fix errors unmasked by re-enabling error_reporting(E_ALL)
- config.example.php: error_reporting(0) -> E_ALL (live config.php matches); add JELLYFIN_URL/JELLYFIN_API_KEY placeholders

- history.php, jellyfin.php: drop require of nonexistent includes/auth.php + AuthMiddleware call (router enforces auth centrally) — both endpoints were fataling on every request

- remove stale kb_intent_generator .bak files from deployed tree

DB (not in repo): kb_facts.fact_value TEXT -> MEDIUMTEXT; ha/entity_map had failed every write since Jul 2 at >64KB

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:43:37 -05:00
root 8f6be645ed Fix auto-detect agent install button: commands shown for Windows/Linux no longer use flags the scripts dont actually support (install-windows.ps1 has no -JarvisUrl/-Key params, install.sh has no --jarvis-url/--key flags), download links use the current page origin instead of a hardcoded URL thats unreachable from outside the LAN, Windows command reflects the new no-Python exe install, fixed the top-level install-agent.sh which still pointed at the pre-migration DO server IP (165.22.1.228) and would have failed outright 2026-07-07 12:43:54 -05:00
root 185d2c889f Fix AGENT button showing the wrong machine: same-subnet fallback matched whichever agent happened to be first in the list, not the actual browsing machine — happens whenever a LAN client hits the dashboard via hairpin NAT/external hostname and the server sees the same reflected IP for every visitor. Exact-match only now; falls through to not-detected instead of misreporting another real machine as yours. 2026-07-07 12:41:53 -05:00
root b5c47d898b Windows agent v3.2: build standalone PyInstaller exe (no Python/pywin32 install needed on target machines), add frozen-mode self-update (rename-swap since a running exe cant be overwritten in place), simplify installer accordingly, fix default install URL (jarvis.orbishosting.com default port is unreachable from outside the LAN - use :1972), fix stale windows=3.0 in the admin panel version-check map 2026-07-07 12:20:22 -05:00
root ffc01c3d4a Update infra doc: WAN IP change + full FortiGate VIP list, JARVIS auto-deploy pipeline restoration, admin panel fixes (Works/Site Health/Email Intelligence), backup systems audit + fixes across all 4 systems, Blair HQ Backup Downloads feature 2026-07-07 11:29:45 -05:00
root 156b210184 jarvis-backup.sh: was DB-only, now also backs up /var/www/jarvis, /opt/jarvis-arc, nginx site config, systemd unit, and root crontab — a DB dump alone was useless without the app code and daemon needed to actually restore JARVIS. Also fixed a typo (SIYE->SIZE) that silently broke the backup-size log line. 2026-07-07 10:54:24 -05:00
root 09f73edb0b Fix silent compose failures: poll actual job status in the compose modal instead of a fire-and-forget dispatch toast; add Groq 429 retry-with-backoff using its own rate-limit-reset header (12k tokens/min tier gets exhausted by gmail_triage alone) 2026-07-07 10:49:08 -05:00
root a4336ebdd6 Fix Email Intelligence inbox 404: was proxying to the old DO-hosted JARVIS (165.22.1.228/api/email), a dead endpoint since the move to VM211. Now reads email_triage directly, matching the pattern email_action_items already used. 2026-07-07 10:06:48 -05:00
root e7f555fff1 reactor.py: fix Ollama silent-empty-return bug (no error checking on API response), bump Ollama timeout 30s->90s (cold model loads took 15s+ alone), add exception type to fallback log lines 2026-07-07 09:25:25 -05:00
root 8034fc67e9 Fix field-name mismatches between PHP dispatcher and Python reactor: compose_email (recipient/subject/auto_send -> to_email/subject_hint/send) and send_reply (content -> body) were silently dropping the actual recipient, subject, and user-edited content on every dispatch 2026-07-07 09:01:47 -05:00
root 48b2a8523a reactor.py: fix llm_call() silently skipping fallback for explicit provider requests, add /comms/sent/{id}/send endpoint for approving queued drafts 2026-07-07 08:58:36 -05:00
root 5ff4f3e311 Outbox: add SEND button for queued drafts (compose previously had no way to actually send), fix VIEW showing body via list endpoint that omits it 2026-07-07 08:58:20 -05:00
root 24b96e809c Site Health tab: fix parkerslingshotrentals.com label + widen cards for full domain names; fix sites freshness-check interval mismatch (300s vs 180s cron) causing checks to feel far apart 2026-07-07 08:41:52 -05:00
root 8567018cc3 Test: verify auto-deploy pipeline end-to-end 2026-07-07 08:25:12 -05:00
root f8a095f783 Fix Works-tab last-run detection bugs, add generic live-log popup for Facts/Stats/Calendar workers, fix webhook branch check (master not main) and log path 2026-07-07 07:58:55 -05:00
root e060ff0c63 Merge origin/master history (ours: live production content is authoritative, origin was stale) 2026-07-07 07:55:58 -05:00
root 588cfe3f10 Adopt live production state as git baseline 2026-07-07 07:55:47 -05:00
root 3db329e925 Correct Pioneer VSX-822: wired through a 4th WiFi extender, not dual-NIC 2026-07-06 23:41:34 -05:00
root c01805316a Correct Pioneer VSX-822 attribution: confirmed at .100, note dual-MAC discrepancy 2026-07-06 23:38:34 -05:00
root 20b510186a Add network equipment and client device inventory (Section 14) from live ARP scan + manual confirmation 2026-07-06 23:34:31 -05:00
root 9b5e16660a Jellyfin: regenerate stale admin API token, document real admin username 2026-07-06 14:29:43 -05:00
root 7020d445b3 Update infra doc: MediaStack/Jellyfin verified detail, NordVPN LAN Discovery + wg0 lockout fix, VMID corrections 2026-07-06 13:09:02 -05:00
myron 80b0dfc583 Document code review + fixes across all 4 DO-hosted business sites (2026-07-06) 2026-07-06 02:58:28 -05:00
myron 30e2bc093c Document WEB HOST agent install and timezone freshness-check fix 2026-07-05 21:56:23 -05:00
myron a29670e93d Fix WEB HOST stats never populating (DO server had no monitoring agent installed) and a timezone bug in freshness checks (America/Chicago default timezone + strtotime() on naive UTC MySQL timestamps caused sites/proxmox/ollama facts to never refresh, and could have suppressed the KB intent generator's scheduled runs) 2026-07-05 21:30:53 -05:00
myron 10350cbcd8 Update infrastructure reference: document 2026-07-06 security review and fixes 2026-07-05 20:13:21 -05:00
myron 24bc876c1d Fix code review findings: unauthenticated infra doc + backup file exposure, onclick-attribute XSS breakouts (admin panel and front-end), plaintext calendar passwords, k()->j() typo, wildcard CORS, session cookie hardening (HttpOnly/SameSite) 2026-07-05 20:09:52 -05:00
myron cfb5b2a3f9 Document ChuckCo Time Keeper code review fixes and admin login rate limiting 2026-07-05 19:17:14 -05:00
myron 0b1a19d9de Update infrastructure reference: fix stale JARVIS port, rotate GitHub PAT reference, add ChuckCo Time Keeper site, FortiGate DNS change, git/repo management section 2026-07-05 18:58:56 -05:00
myron 66a5443f22 Document Homebridge backup switch from MSP360 to Proxmox vzdump 2026-07-04 12:21:53 -05:00
myron e5536b077c Document MSP360 backup status dashboard integration 2026-07-03 08:03:54 -05:00
myron a292411d52 Stop hardcoding a generic watch_services list on every agent install
The installer wrote the same service list (ollama, homeassistant, mysql,
mariadb, nginx, apache2, docker) onto every host regardless of what it
actually runs, causing false "Service Down" alerts on hosts that never
installed those services. Default is now empty; watch_services should be
set per-host to match what's actually running there.
2026-07-02 20:28:42 -05:00
myron 26b501b600 fix: address 8 code-review findings in KB topic manager
- Fix 1 (ORDER BY): use table alias t.id to force integer PK ordering;
  topic_id alias was causing alphabetical sort, breaking rotation offsets
- Fix 2 (PDOException): wrap INSERT/UPDATE in try/catch in kb_topic_save;
  duplicate topic_id now returns clean JSON error instead of HTTP 500
- Fix 3 (XSS/onclick): DEL button passes only t.id; tmDelete looks up
  name from _topicsData, removing JSON.stringify from onclick attribute
- Fix 4 (validation): topic_id must contain at least one [a-z0-9] after
  sanitization; "@@@" to "___" no longer passes required-field check
- Fix 5 (stale logs): guard comment and log messages updated from 20h to
  4h to match the actual 14400s threshold
- Fix 6 (dedup): tmEsc() replaced with existing esc() throughout; removed
  duplicate function definition
- Fix 7 (toggle rollback): tmToggle uses inline fetch to revert el.checked
  on API failure instead of leaving the UI in wrong state
- Fix 8 (confirm): tmDelete uses openModal confirmation instead of
  confirm() dialog, consistent with project live-popup convention

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 10:26:47 -05:00
myron 7327104612 feat: DB-driven KB topic manager — admin UI + migration
- Create kb_generator_topics table (140 topics migrated from hardcoded array)
- kb_intent_generator.php now loads active topics from DB instead of $BATCHES array
- Admin panel: MANAGE TOPICS button opens full topic manager modal
  - Browse all 140 topics with search, category, and active/disabled filters
  - Inline active toggle per topic (checkbox)
  - Add new topic form (topic_id, category, name, description)
  - Edit existing topics (all fields)
  - Delete topics with confirmation
  - Run count display
- Backend API cases: kb_topics, kb_topic_save, kb_topic_delete, kb_topic_toggle
- Topics can now be added/managed without any code changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 07:04:20 -05:00
myron a13f750846 feat(kb-intent): expand to 140 topics, 109 KB topic library
Rich topic library across mathematics (12), sciences (13), history (10),
government/economics, literature, life skills (13), technology (5),
Texas/local (4), national US (3), world affairs (5), human sexuality (2),
astronomy (3), space (2), culture/arts (8), sports (8), food/drink (4),
home/DIY (3), wellness (4), tech continued (3), national continued (2),
world continued (2), medicine (3), math continued (2), communication (2).

Each topic has detailed multi-subtopic descriptions (~200 chars each)
vs prior 8-word descriptions — significantly richer Groq prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-02 06:50:12 -05:00
myron 3c8dd8e206 feat(kb-intent): 122-topic rotation engine, every-6h cron
- Expanded $BATCHES from 25 to 122 topics across: life skills, personal
  finance, Texas/local, US national, world geopolitics, human sexuality
  (educational), deep astronomy (15 topics), and space exploration (15 topics)
- Rotation engine: 25 topics per run cycling through all 122 in order;
  wraps to topic 1 on cycle complete (~30h full cycle at 6h interval)
- batch_offset tracked in kb_facts for cross-run persistence
- Cron changed from 0 3 * * * to 0 */6 * * * (every 6 hours)
- 20h skip guard reduced to 4h so 6h cron isn't blocked
- max_tokens bumped 3500 → 5000 to prevent JSON truncation errors
- JSON extraction: partial recovery + raw snippet logged on failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:53:57 -05:00
myron 554488aefa fix(kb-intent): reduce batch size 40→20 intents, raise max_tokens to 5000
Token budget was too low for 40 intents — responses were truncated mid-JSON,
causing "No JSON array found" on ~88% of batches. Fixes 880 errors/run.
Also adds partial-JSON truncation recovery and raw response debug logging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:44:22 -05:00
myron e99c3aa171 feat(admin): KB intent generator — force run, background on close, history panel
- Always runs when triggered from admin (JARVIS_FORCE_RUN=1 bypasses 20h guard)
- Closing popup stops UI polling but server job continues; toast confirms background run
- History panel at top of popup shows last success time/count + failures from last 7 days
- New intent_gen_history backend case parses cron.log for status summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:39:41 -05:00
myron 2528b37ea1 docs: update infrastructure reference for July 2026 session
- VM 210: correct SSH path, add llava:7b vision model
- Add Arc Reactor section (service, logs, SETUP button, vision cascade)
- JARVIS AI tiers: llama3.2 → llama3.1:8b, add vision tier
- Backup Systems: add JARVIS database backup (path, format, retention)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:32:31 -05:00
myron 46dabe3c31 feat(admin): Arc Reactor SETUP button — live log popup
- Replace arcSetup() confirm/toast with openModal() live popup
- Add arc_setup_log PHP backend (polls /var/log/jarvis/arc-setup.log)
- Color-coded line output: green=ok/done/active, red=error/fail, yellow=warn
- Auto-detects completion keywords and updates status header
- CLOSE button stops polling and refreshes worker table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014p87VFec84hNaf2WpvmLrW
2026-07-01 22:30:39 -05:00
myron d6a1fc9456 Fix backup script: tar.gz output, lockfile, correct log path, install at /usr/local/bin
- Renamed output to jarvis_backup_TIMESTAMP.tar.gz (admin panel pattern)
- Log now goes to /var/backups/jarvis/backup.log (admin panel reads this)
- Lockfile at /var/backups/jarvis/backup.lock (prevents concurrent runs)
- tmpdir + trap cleanup for safe temp handling
- Install at /usr/local/bin/jarvis-backup.sh (admin panel trigger path)
2026-07-01 22:20:58 -05:00
myron 2f74b98bbc Admin panel: add Arc Reactor SETUP button, fix RESTART to use systemctl
- SETUP button in Workers > Daemons runs full deploy:
  copies reactor.py + requirements.txt from deploy/, creates venv,
  pip install, installs jarvis-arc.service, systemctl enable + restart
- RESTART button now calls systemctl restart jarvis-arc (was nohup pkill)
- arcSetup() JS function with confirm dialog and loading state
- Setup log written to /var/log/jarvis/arc-setup.log
2026-07-01 22:13:28 -05:00
myron ed15ff12dd Add _vision_call() helper with Claude -> Ollama -> graceful fallback
- Centralises all vision logic in one place instead of duplicated inline blocks
- Claude remains primary; set OLLAMA_VISION_MODEL env var to enable a local
  vision model (e.g. llava, moondream) as automatic fallback
- Graceful degradation message when no vision provider is available
- Both handle_screenshot and handle_vision now use _vision_call()
2026-07-01 21:37:11 -05:00
myron 3f18cec739 Add missing email_sent/email_actions/email_triage tables to schema; add sent_at column 2026-07-01 21:29:48 -05:00
myron 8911645c20 Add jarvis-backup.sh — daily mysqldump with 7-day retention
Backup script was never migrated when JARVIS moved from DO to VM 211.
Runs daily at 3am, stores compressed dumps in /var/backups/jarvis/,
logs to /var/log/jarvis/backup.log.
2026-07-01 21:22:02 -05:00
28 changed files with 9754 additions and 8112 deletions
+29 -29
View File
@@ -1,29 +1,29 @@
# JARVIS
Iron Man-style AI assistant for home and network management.
## Features
- Home Assistant control (lights, climate, scenes, switches)
- Proxmox VM management (start/stop/status)
- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API
- Real-time status bar (HA, Proxmox, DigitalOcean)
- Iron Man HUD at jarvis.orbishosting.com
## Stack
- PHP 8.x / Apache / MySQL on Ubuntu 24.04
- Ollama VM at 10.48.200.95 (llama3.2:1b)
- Groq API (llama-3.3-70b / compound-mini with web search)
- Claude API (Anthropic) final fallback
## Setup
cp api/config.example.php api/config.php
Fill in all credentials in config.php before running.
## Key Files
- public/index.html Iron Man HUD frontend
- public/api.php API router
- api/config.example.php Config template
- api/endpoints/chat.php 4-tier chat handler
- api/endpoints/facts_collector.php HA entity sync cron
- api/lib/kb_engine.php KB intent engine
- api/lib/db.php PDO database wrapper
# JARVIS
Iron Man-style AI assistant for home and network management.
## Features
- Home Assistant control (lights, climate, scenes, switches)
- Proxmox VM management (start/stop/status)
- 4-tier chat: KB intents > Groq cloud > Ollama local > Claude API
- Real-time status bar (HA, Proxmox, DigitalOcean)
- Iron Man HUD at jarvis.orbishosting.com
## Stack
- PHP 8.x / Apache / MySQL on Ubuntu 24.04
- Ollama VM at 10.48.200.95 (llama3.2:1b)
- Groq API (llama-3.3-70b / compound-mini with web search)
- Claude API (Anthropic) final fallback
## Setup
cp api/config.example.php api/config.php
Fill in all credentials in config.php before running.
## Key Files
- public/index.html Iron Man HUD frontend
- public/api.php API router
- api/config.example.php Config template
- api/endpoints/chat.php 4-tier chat handler
- api/endpoints/facts_collector.php HA entity sync cron
- api/lib/kb_engine.php KB intent engine
- api/lib/db.php PDO database wrapper
+17 -38
View File
@@ -6,20 +6,26 @@
.DESCRIPTION
Installs JARVIS Agent as a Windows Service that auto-starts at boot.
Requires: PowerShell 5.1+, internet access, and Administrator rights.
No Python installation needed — this installs the standalone .exe build.
.EXAMPLE
# Interactive install (prompts for registration key):
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex
# Silent install with key:
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex
#>
$ErrorActionPreference = 'Stop'
$JARVIS_URL = 'https://jarvis.orbishosting.com'
# Fixed 2026-07-07: jarvis.orbishosting.com on the default port (80/443) is not
# reachable from outside the LAN at all (no FortiGate VIP forwards it) — every
# external install using the old default URL would have failed outright. Port
# 1972 is the confirmed-working external path (same fix applied to the GitHub
# webhook the same day).
$JARVIS_URL = 'http://jarvis.orbishosting.com:1972'
$INSTALL_DIR = 'C:\ProgramData\jarvis-agent'
$SERVICE_NAME = 'JARVISAgent'
$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py"
$AGENT_EXE = "$INSTALL_DIR\jarvis-agent-windows.exe"
$CONFIG_FILE = "$INSTALL_DIR\config.json"
function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan }
@@ -39,49 +45,23 @@ if ($existing) {
Start-Sleep 2
}
try {
& python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null
if (Test-Path $AGENT_EXE) { & $AGENT_EXE remove 2>$null }
} catch {}
Write-OK "Existing service removed."
}
# ── Check / install Python ────────────────────────────────────────────────────
Write-Step "Checking Python..."
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) {
Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run."
}
winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User")
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" }
}
$pyVersion = & python --version 2>&1
Write-OK $pyVersion
# ── Install pywin32 ───────────────────────────────────────────────────────────
Write-Step "Checking pywin32..."
$checkWin32 = & python -c "import win32service; print('ok')" 2>&1
if ($checkWin32 -ne 'ok') {
Write-Host " Installing pywin32..." -ForegroundColor Yellow
& python -m pip install --quiet pywin32
& python -m pywin32_postinstall -install 2>$null
Write-OK "pywin32 installed."
} else {
Write-OK "pywin32 already installed."
}
# ── Create install dir ────────────────────────────────────────────────────────
Write-Step "Creating install directory..."
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
Write-OK $INSTALL_DIR
# ── Download agent script ─────────────────────────────────────────────────────
# ── Download agent exe ─────────────────────────────────────────────────────────
# No Python/pywin32 dependency anymore — this is a self-contained PyInstaller
# build with everything it needs bundled in.
Write-Step "Downloading JARVIS agent..."
try {
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing
Write-OK "Agent downloaded to $AGENT_SCRIPT"
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.exe" -OutFile $AGENT_EXE -UseBasicParsing
Write-OK "Agent downloaded to $AGENT_EXE"
} catch {
Write-Fail "Failed to download agent: $_"
}
@@ -121,8 +101,7 @@ Write-OK "Config written to $CONFIG_FILE"
# ── Install Windows Service ───────────────────────────────────────────────────
Write-Step "Installing Windows service..."
$pyPath = (Get-Command python).Source
& $pyPath "$AGENT_SCRIPT" --startup auto install
& $AGENT_EXE --startup auto install
if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." }
Write-OK "Service '$SERVICE_NAME' installed."
Binary file not shown.
+53 -21
View File
@@ -35,7 +35,7 @@ INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent")
CONFIG_PATH = INSTALL_DIR / "config.json"
STATE_PATH = INSTALL_DIR / "state.json"
LOG_PATH = INSTALL_DIR / "jarvis-agent.log"
AGENT_VERSION = "3.1"
AGENT_VERSION = "3.2"
# Set by the service wrapper so self_update knows to stop instead of exec
_is_service = False
@@ -92,7 +92,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
@@ -109,7 +109,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
def api_get(url: str, headers: dict = {}, timeout: int = 10,
ssl_verify: bool = True) -> dict:
req = urllib.request.Request(url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
@@ -375,18 +375,28 @@ def _sysinfo_snapshot() -> dict:
# ── Self-update ────────────────────────────────────────────────────────────────
def self_update(cfg: dict) -> bool:
# Added: supports both script-mode (plain .py, run via a system Python) and
# frozen-mode (standalone PyInstaller .exe — sys.frozen is set, __file__ isn't
# meaningful/writable the way it is for a real .py file on disk). A running
# .exe can't be overwritten in place on Windows, but CAN be renamed while
# running, so frozen mode uses a download-new/rename-old/rename-new swap
# instead of the direct overwrite the script-mode path uses.
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else ""
is_frozen = bool(getattr(sys, "frozen", False))
if is_frozen:
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.exe" if jarvis_url else ""
else:
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else ""
update_url = cfg.get("update_url", default_update_url)
if not update_url:
return False
script_path = os.path.abspath(__file__)
target_path = os.path.abspath(sys.executable) if is_frozen else os.path.abspath(__file__)
ssl_verify = bool(cfg.get("ssl_verify", True))
try:
# Download expected hash
hash_url = update_url + ".sha256"
req_hash = urllib.request.Request(hash_url)
req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req_hash.add_header("Host", _host_header)
expected_hash = None
@@ -397,13 +407,13 @@ def self_update(cfg: dict) -> bool:
except Exception:
pass
# Download new script
# Download new script/exe
req = urllib.request.Request(update_url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
new_content = resp.read()
# Verify hash
@@ -413,20 +423,42 @@ def self_update(cfg: dict) -> bool:
log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting")
return False
with open(script_path, "rb") as f:
with open(target_path, "rb") as f:
current = f.read()
if new_content != current:
log(f"Update verified — replacing {script_path} and restarting...")
with open(script_path, "wb") as f:
if new_content == current:
return False
log(f"Update verified — replacing {target_path} and restarting...")
if is_frozen:
# Can't overwrite a running exe, but can rename it and drop the new
# one in its place; the old copy is cleaned up on the next update.
old_path = target_path + ".old"
new_path = target_path + ".new"
with open(new_path, "wb") as f:
f.write(new_content)
if _is_service:
# Signal the main loop to exit; SCM failure-recovery will restart us
log("Running as service — stopping for SCM-managed restart after update.")
_stop_event.set()
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
return True
return False
try:
if os.path.exists(old_path):
os.remove(old_path)
except Exception:
pass
os.rename(target_path, old_path)
os.rename(new_path, target_path)
else:
with open(target_path, "wb") as f:
f.write(new_content)
if _is_service:
# Signal the main loop to exit; SCM failure-recovery will restart us
log("Running as service — stopping for SCM-managed restart after update.")
_stop_event.set()
elif is_frozen:
# sys.argv[0] is already the exe's own path for a frozen app — don't
# prepend sys.executable again or the new process misreads its own
# path as a command-line argument.
os.execv(sys.executable, sys.argv)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
return True
except Exception as e:
log(f"Self-update check failed: {e}")
return False
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {}
def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
watch = cfg.get("watch_services", [])
statuses = []
for svc in watch:
try:
+4 -1
View File
@@ -47,8 +47,11 @@ define(chr(39)+'HA_TOKEN'.chr(39), chr(39)+'YOUR_HA_LONG_LIVED_TOKEN'.chr(39));
define(chr(39)+'SESSION_LIFETIME'.chr(39), 86400 * 7);
define(chr(39)+'SITE_URL'.chr(39), chr(39)+'https://jarvis.orbishosting.com'.chr(39));
error_reporting(0);
error_reporting(E_ALL);
ini_set(chr(39)+'display_errors'.chr(39), 0);
ini_set(chr(39)+'log_errors'.chr(39), 1);
ini_set(chr(39)+'error_log'.chr(39), chr(39)+'/var/log/apache2/jarvis_errors.log'.chr(39));
date_default_timezone_set(chr(39)+'America/Chicago'.chr(39));
define('JELLYFIN_URL', 'http://10.48.200.33:8096');
define('JELLYFIN_API_KEY', 'your-jellyfin-api-key');
+94 -94
View File
@@ -1,94 +1,94 @@
<?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms)
function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat");
usleep(200000);
$s2 = file_get_contents("/proc/stat");
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2);
$idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
}
$memLines = [];
foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v);
}
$memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut);
// Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = [];
foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true;
}
// Site health from kb_facts
$siteLabels = [
"jarvis" => "jarvis.orbishosting.com:1972",
"tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com",
];
$sites = [];
$rows = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key"
);
foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"];
}
$uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query(
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
);
$doMet = [];
if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [
"cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--",
"online" => true,
];
}
echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true,
"cpu_pct" => getCpuPct(),
"memory" => [
"total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
],
"disk_used_pct" => $diskPct,
"load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap,
"sites" => $sites,
"do_server" => $doMet,
"timestamp" => date("c"),
]);
<?php
// Digital Ocean server monitoring — read local /proc directly (no SSH loopback)
// CPU usage (sample over 200ms)
function getCpuPct(): float {
$s1 = file_get_contents("/proc/stat");
usleep(200000);
$s2 = file_get_contents("/proc/stat");
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s1, $m1);
preg_match("/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m", $s2, $m2);
$idle1 = $m1[4]; $total1 = $m1[1]+$m1[2]+$m1[3]+$m1[4];
$idle2 = $m2[4]; $total2 = $m2[1]+$m2[2]+$m2[3]+$m2[4];
$dt = $total2 - $total1;
return $dt > 0 ? round((1 - ($idle2 - $idle1) / $dt) * 100, 1) : 0;
}
$memLines = [];
foreach (file("/proc/meminfo") as $l) {
[$k, $v] = explode(":", $l, 2) + [null, null];
if ($k) $memLines[trim($k)] = (int)trim($v);
}
$memTotal = $memLines["MemTotal"] ?? 0;
$memFree = $memLines["MemAvailable"] ?? 0;
$memUsed = $memTotal - $memFree;
$uptime = (int)explode(" ", file_get_contents("/proc/uptime"))[0];
$load = (float)explode(" ", file_get_contents("/proc/loadavg"))[0];
$dfOut = shell_exec("df / | tail -1 | awk {print }") ?? "";
$diskPct = trim($dfOut);
// Services
$svcNames = ["nginx", "php8.3-fpm", "mariadb", "redis-server", "jarvis-arc", "jarvis-agent"];
$svcMap = [];
foreach ($svcNames as $s) {
$status = trim(shell_exec("systemctl is-active " . escapeshellarg($s) . " 2>/dev/null") ?? "");
if ($status === "active") $svcMap[$s] = true;
}
// Site health from kb_facts
$siteLabels = [
"jarvis" => "jarvis.orbishosting.com",
"tomsjavajive" => "tomsjavajive.com",
"epictravelexp"=> "epictravelexpeditions.com",
"parkersling" => "parkerslingshotrentals.com",
"orbishosting" => "orbishosting.com",
"orbisportal" => "orbis.orbishosting.com",
"tomtomgames" => "tomtomgames.com",
];
$sites = [];
$rows = JarvisDB::query(
"SELECT fact_key, fact_value FROM kb_facts WHERE category='sites' AND updated_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE) ORDER BY fact_key"
);
foreach ($rows as $r) {
$label = $siteLabels[$r["fact_key"]] ?? $r["fact_key"];
$sites[$label] = $r["fact_value"];
}
$uptimeDays = intdiv($uptime, 86400);
$uptimeHrs = intdiv($uptime % 86400, 3600);
// DO server agent metrics (jarvis-do agent reporting via Tailscale)
$doAgent = JarvisDB::query(
"SELECT metric_data FROM agent_metrics WHERE agent_id='jarvis-do_orbis' AND metric_type='system' ORDER BY recorded_at DESC LIMIT 1"
);
$doMet = [];
if (!empty($doAgent[0]['metric_data'])) {
$dm = json_decode($doAgent[0]['metric_data'], true) ?? [];
$doMet = [
"cpu" => $dm['cpu_percent'] ?? 0,
"mem" => $dm['memory']['percent'] ?? 0,
"disk" => (int)($dm['disk'][0]['percent'] ?? 0),
"uptime" => $dm['uptime']['human'] ?? "--",
"online" => true,
];
}
echo json_encode([
"ip" => "10.48.200.211", // JARVIS VM (PVE1)
"reachable" => true,
"cpu_pct" => getCpuPct(),
"memory" => [
"total_mb" => round($memTotal / 1024),
"used_mb" => round($memUsed / 1024),
"percent" => $memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0,
],
"disk_used_pct" => $diskPct,
"load_1m" => $load,
"uptime" => "{$uptimeDays}d {$uptimeHrs}h",
"services" => $svcMap,
"sites" => $sites,
"do_server" => $doMet,
"timestamp" => date("c"),
]);
+259 -251
View File
@@ -1,251 +1,259 @@
<?php
/**
* JARVIS Facts Collector
* HTTP endpoint: /api/facts/collect (POST or GET)
* CLI/cron: php facts_collector.php
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
*/
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
if (!class_exists('KBEngine')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/kb_engine.php';
}
function collect_all(): array {
$results = [];
$ttl = 300; // 5-minute TTL on live facts
// Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh.
$fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query(
'SELECT updated_at FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$cat]
);
if (empty($row[0]['updated_at'])) return false;
return (time() - strtotime($row[0]['updated_at'])) < $secs;
};
// ── System ────────────────────────────────────────────────────────────
try {
$stat1 = file_get_contents('/proc/stat');
usleep(200000);
$stat2 = file_get_contents('/proc/stat');
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
$dIdle = $cpu2[3] - $cpu1[3];
$dTotal = array_sum($cpu2) - array_sum($cpu1);
$cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl);
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$total = round($mem['MemTotal'] / 1048576, 1);
$avail = round($mem['MemAvailable'] / 1048576, 1);
$used = round($total - $avail, 1);
$free = round($mem['MemFree'] / 1048576, 1);
$memPct = $total > 0 ? round($used / $total * 100) : 0;
KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl);
KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl);
KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl);
KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl);
$la = explode(' ', file_get_contents('/proc/loadavg'));
KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl);
KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl);
KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl);
$sec = (int) file_get_contents('/proc/uptime');
KBEngine::storeFact('system', 'uptime',
intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours',
'local', $ttl);
$df = disk_free_space('/');
$dt = disk_total_space('/');
KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl);
$results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)";
} catch (Exception $e) {
$results['system'] = 'error: ' . $e->getMessage();
}
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
try {
$rows = JarvisDB::query(
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
$total = count($rows);
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
$results['network'] = "ok ({$online}/{$total} online)";
} catch (Exception $e) {
$results['network'] = 'error: ' . $e->getMessage();
}
// ── Proxmox (TTL 10 min) ─────────────────────────────────────────────
if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
$vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth);
$cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth);
if (isset($nd['data'])) {
$cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1);
$memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1);
$memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1);
$memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl);
}
$all = array_merge($vms['data'] ?? [], $cts['data'] ?? []);
$running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running'));
KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl);
$results['proxmox'] = "ok ({$running}/" . count($all) . " running)";
} else {
$results['proxmox'] = 'skipped (no token)';
}
} catch (Exception $e) {
$results['proxmox'] = 'error: ' . $e->getMessage();
}
// ── Home Assistant — skipped (HA agent pushes entities every 30s) ────
$results['ha'] = 'skipped (agent push active)';
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);;
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
$results['do_server'] = "ok ({$doStatus})";
} catch (Exception $e) {
$results['do_server'] = 'error: ' . $e->getMessage();
}
// ── Ollama (TTL 15 min) ───────────────────────────────────────────────
if ($fresh('ollama', 900)) {
$results['ollama'] = 'skipped (fresh)';
} else try {
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$models = json_decode($resp, true)['models'] ?? [];
$names = array_column($models, 'name');
KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null);
KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl);
foreach ($models as $m) {
JarvisDB::execute(
'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?)
ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()',
[$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)]
);
}
$results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')';
} else {
KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl);
$results['ollama'] = 'unreachable (VM may be booting)';
}
} catch (Exception $e) {
$results['ollama'] = 'error: ' . $e->getMessage();
}
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
if ($fresh('sites', 300)) {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
"jarvis" => "http://127.0.0.1",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
'orbishosting' => 'https://orbishosting.com',
'orbisportal' => 'https://orbis.orbishosting.com',
'tomtomgames' => 'https://tomtomgames.com',
];
$down = [];
foreach ($sites as $key => $url) {
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_NOBODY => true,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
KBEngine::storeFact('sites', $key, $status, $url, 180);
if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
} catch (Exception $e) {
$results['sites'] = 'error: ' . $e->getMessage();
}
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
return $results;
}
function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 5,
]);
$resp = curl_exec($ch);
curl_close($ch);
return $resp ? (json_decode($resp, true) ?? []) : [];
}
// ── Entry point ───────────────────────────────────────────────────────────
$results = collect_all();
if ($isCLI) {
echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n";
foreach ($results as $k => $v) {
echo " {$k}: {$v}\n";
}
} else {
echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]);
}
<?php
/**
* JARVIS Facts Collector
* HTTP endpoint: /api/facts/collect (POST or GET)
* CLI/cron: php facts_collector.php
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
*/
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
if (!class_exists('KBEngine')) {
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/kb_engine.php';
}
function collect_all(): array {
$results = [];
$ttl = 300; // 5-minute TTL on live facts
// Returns true if a fact category has been updated within $secs seconds.
// Prevents expensive external calls when data is still fresh.
// Comparison is done entirely in SQL (via NOW()) rather than PHP's time()/strtotime()
// — this file's config.php sets date_default_timezone_set('America/Chicago'), which
// makes strtotime() misinterpret MySQL's naive (UTC) datetime strings as being in
// Chicago time, throwing every freshness check off by the UTC offset (previously
// caused "sites" to always look artificially fresh and never actually refresh).
$fresh = function(string $cat, int $secs): bool {
$row = JarvisDB::query(
'SELECT (updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)) AS is_fresh FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
[$secs, $cat]
);
if (empty($row)) return false;
return (bool) $row[0]['is_fresh'];
};
// ── System ────────────────────────────────────────────────────────────
try {
$stat1 = file_get_contents('/proc/stat');
usleep(200000);
$stat2 = file_get_contents('/proc/stat');
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
$dIdle = $cpu2[3] - $cpu1[3];
$dTotal = array_sum($cpu2) - array_sum($cpu1);
$cpuPct = $dTotal > 0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0;
KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl);
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$total = round($mem['MemTotal'] / 1048576, 1);
$avail = round($mem['MemAvailable'] / 1048576, 1);
$used = round($total - $avail, 1);
$free = round($mem['MemFree'] / 1048576, 1);
$memPct = $total > 0 ? round($used / $total * 100) : 0;
KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl);
KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl);
KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl);
KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl);
$la = explode(' ', file_get_contents('/proc/loadavg'));
KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl);
KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl);
KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl);
$sec = (int) file_get_contents('/proc/uptime');
KBEngine::storeFact('system', 'uptime',
intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours',
'local', $ttl);
$df = disk_free_space('/');
$dt = disk_total_space('/');
KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl);
KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl);
$results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)";
} catch (Exception $e) {
$results['system'] = 'error: ' . $e->getMessage();
}
// ── Network — read from agent DB (agents push status, DO can't ping LAN IPs) ──
try {
$rows = JarvisDB::query(
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
$total = count($rows);
KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl);
KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl);
KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl);
$results['network'] = "ok ({$online}/{$total} online)";
} catch (Exception $e) {
$results['network'] = 'error: ' . $e->getMessage();
}
// ── Proxmox (TTL 10 min) ─────────────────────────────────────────────
if ($fresh('proxmox', 600)) {
$results['proxmox'] = 'skipped (fresh)';
} else try {
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
$base = 'https://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
$auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL;
$nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth);
$vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth);
$cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth);
if (isset($nd['data'])) {
$cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1);
$memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1);
$memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1);
$memPct = $memT > 0 ? round($memU / $memT * 100) : 0;
KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl);
}
$all = array_merge($vms['data'] ?? [], $cts['data'] ?? []);
$running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running'));
KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl);
KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl);
$results['proxmox'] = "ok ({$running}/" . count($all) . " running)";
} else {
$results['proxmox'] = 'skipped (no token)';
}
} catch (Exception $e) {
$results['proxmox'] = 'error: ' . $e->getMessage();
}
// ── Home Assistant — skipped (HA agent pushes entities every 30s) ────
$results['ha'] = 'skipped (agent push active)';
// ── Digital Ocean ─────────────────────────────────────────────────────
try {
exec("ping -c1 -W1 165.22.1.228 > /dev/null 2>&1", $o2, $doCode);;
$doStatus = ($doCode === 0) ? 'online' : 'unreachable';
KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl);
$results['do_server'] = "ok ({$doStatus})";
} catch (Exception $e) {
$results['do_server'] = 'error: ' . $e->getMessage();
}
// ── Ollama (TTL 15 min) ───────────────────────────────────────────────
if ($fresh('ollama', 900)) {
$results['ollama'] = 'skipped (fresh)';
} else try {
$ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434';
$ch = curl_init($ollamaHost . '/api/tags');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$models = json_decode($resp, true)['models'] ?? [];
$names = array_column($models, 'name');
KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null);
KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl);
KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl);
foreach ($models as $m) {
JarvisDB::execute(
'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?)
ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()',
[$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)]
);
}
$results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')';
} else {
KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl);
$results['ollama'] = 'unreachable (VM may be booting)';
}
} catch (Exception $e) {
$results['ollama'] = 'error: ' . $e->getMessage();
}
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
// Fixed 2026-07-07: this guard was 300s but cron only runs every 180s, so sites
// were effectively only re-checked every OTHER run (~6 min gaps, felt "far apart").
// 170s keeps it just under the cron cadence so it re-checks on every run.
if ($fresh('sites', 170)) {
$results['sites'] = 'skipped (fresh)';
} else try {
$sites = [
"jarvis" => "http://127.0.0.1",
'tomsjavajive' => 'https://tomsjavajive.com',
'epictravelexp'=> 'https://epictravelexpeditions.com',
'parkerslingshotrentals' => 'https://parkerslingshotrentals.com',
'orbishosting' => 'https://orbishosting.com',
'orbisportal' => 'https://orbis.orbishosting.com',
'tomtomgames' => 'https://tomtomgames.com',
];
$down = [];
foreach ($sites as $key => $url) {
$parsed = parse_url($url);
$host = $parsed['host'] ?? $url;
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
// All JARVIS-hosted sites are served from this same OLS instance.
$localUrl = $url; // external check
$ch = curl_init($localUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_NOBODY => true,
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
KBEngine::storeFact('sites', $key, $status, $url, 180);
if ($status !== 'up') $down[] = "$key($code)";
}
$results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down);
} catch (Exception $e) {
$results['sites'] = 'error: ' . $e->getMessage();
}
// Network device scan is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
// which POSTs nmap results to /api/netscan every 3 minutes.
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
return $results;
}
function pve_api_get(string $url, string $authHeader): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [$authHeader],
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 5,
]);
$resp = curl_exec($ch);
curl_close($ch);
return $resp ? (json_decode($resp, true) ?? []) : [];
}
// ── Entry point ───────────────────────────────────────────────────────────
$results = collect_all();
if ($isCLI) {
echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n";
foreach ($results as $k => $v) {
echo " {$k}: {$v}\n";
}
} else {
echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]);
}
-2
View File
@@ -1,10 +1,8 @@
<?php
// Chat history search endpoint
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
AuthMiddleware::requireAuth();
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) {
-2
View File
@@ -1,9 +1,7 @@
<?php
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../includes/auth.php';
header('Content-Type: application/json');
AuthMiddleware::requireAuth();
$action = $_GET['action'] ?? 'sessions';
+329
View File
@@ -0,0 +1,329 @@
<?php
/**
* JARVIS KB Intent Generator
* Generates 1,000+ educational KB intents via Groq LLM and imports to kb_intents table.
* Also runs a cleanup pass: deduplication, short-response pruning, pattern normalisation.
*
* CLI / cron: /usr/bin/php8.3 /var/www/jarvis/api/endpoints/kb_intent_generator.php
* Schedule: Daily 3 am
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../lib/db.php';
/* ── helpers ── */
function ts(): string { return '[' . date('Y-m-d H:i:s') . ']'; }
function log_line(string $msg): void { echo ts() . ' KB Intent Generator: ' . $msg . "\n"; flush(); }
function groq(string $system, string $user, int $max = 3000, int $retries = 2): ?string {
for ($attempt = 0; $attempt <= $retries; $attempt++) {
if ($attempt > 0) {
log_line(" Retry {$attempt}/{$retries} after rate-limit pause...");
sleep(25);
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'llama-3.3-70b-versatile',
'max_tokens' => $max,
'temperature' => 0.7,
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
],
]),
]);
$raw = curl_exec($ch);
$err = curl_error($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($err || !$raw) continue;
// Check for rate limit response (429)
if (($info['http_code'] ?? 0) === 429) continue;
$d = json_decode($raw, true);
$content = $d['choices'][0]['message']['content'] ?? null;
if ($content !== null) return $content;
}
return null;
}
/* ── normalize a pattern to valid PHP PCRE ── */
function normalize_pattern(string $pat): string {
$pat = trim($pat);
// If pattern already has PCRE delimiters, leave it alone
if (preg_match('/^[\/|~#!@%]/', $pat)) return $pat;
// Strip any (?i) inline flag — we'll add /i at the delimiter level
$pat = preg_replace('/^\(\?i\)/', '', $pat);
// Escape forward slashes inside the pattern
$pat = str_replace('/', '\\/', $pat);
return '/' . $pat . '/i';
}
/* ── run guard: skip if ran within last 4 hours ── */
/* Set JARVIS_FORCE_RUN=1 (env) or pass --force (argv) to bypass */
/* Comparison done in SQL (NOW()) rather than PHP time()/strtotime() this process's
date_default_timezone_set('America/Chicago') makes strtotime() misread MySQL's naive
(UTC) timestamps as Chicago time, throwing elapsed-time checks off by the UTC offset. */
$recentRun = JarvisDB::single(
"SELECT (updated_at > DATE_SUB(NOW(), INTERVAL 14400 SECOND)) AS is_recent FROM kb_facts WHERE category='kb_generator' AND fact_key='last_run'"
);
$forceRun = !empty(getenv('JARVIS_FORCE_RUN')) || (isset($argv[1]) && $argv[1] === '--force');
if (!$forceRun && $recentRun && $recentRun['is_recent']) {
log_line('Skipping ran within last 4 hours. Use --force to override.');
exit(0);
}
if ($forceRun) log_line('Force-run flag set — bypassing 4-hour guard.');
log_line('Starting daily KB intent generation run.');
/* ── load active topics from database ── */
$BATCHES = JarvisDB::query(
"SELECT t.topic_id AS id, t.category, t.topic_name AS topic, t.description AS `desc`
FROM kb_generator_topics t WHERE t.active=1 ORDER BY t.id ASC"
);
if (empty($BATCHES)) {
log_line('ERROR: No active topics in kb_generator_topics table. Add topics via the JARVIS admin panel.');
exit(1);
}
log_line('Loaded ' . count($BATCHES) . ' active topics from database.');
/* ── ROTATION ENGINE: process BATCH_SIZE topics per run, cycling through all ── */
define('BATCH_SIZE', 25);
$totalTopics = count($BATCHES);
$offsetRow = JarvisDB::single(
"SELECT CAST(fact_value AS SIGNED) AS v FROM kb_facts WHERE category='kb_generator' AND fact_key='batch_offset'"
);
$batchOffset = max(0, (int)($offsetRow['v'] ?? 0));
if ($batchOffset >= $totalTopics) $batchOffset = 0;
$nextOffset = ($batchOffset + BATCH_SIZE) % $totalTopics;
$cycleComplete = ($batchOffset + BATCH_SIZE) >= $totalTopics;
$cycleLen = (int)ceil($totalTopics / BATCH_SIZE);
$runBatches = [];
for ($i = 0; $i < BATCH_SIZE; $i++) {
$runBatches[] = $BATCHES[($batchOffset + $i) % $totalTopics];
}
$endIdx = ($batchOffset + BATCH_SIZE - 1) % $totalTopics;
log_line("Rotation: topics " . ($batchOffset + 1) . "" . ($endIdx + 1) . " of {$totalTopics} | cycle = {$cycleLen} runs × 6h = " . ($cycleLen * 6) . "h full cycle.");
if ($cycleComplete) log_line(" ↻ Full cycle complete — restarting from topic 1 next run.");
/* ── system prompt ── */
/* RESTORED 2026-07-07: this and safe_insert() below were lost during the 2026-07-05 rotation-engine
refactor (moving from a hardcoded $BATCHES array to the kb_generator_topics table). Their absence
caused an uncaught TypeError on every run since (undefined $SYSTEM passed to groq()'s non-nullable
string param), silently swallowed by config.php's error_reporting(0) the cron looked like it was
running fine but died instantly on batch 1 every single time. Restored from kb_intent_generator.php.bak2,
with the intent count adjusted from 40 to 20 to match this version's actual per-topic request below. */
$SYSTEM = <<<'SYS'
You are an expert educator generating KB (knowledge-base) intents for an AI assistant called JARVIS.
Each intent is a question/phrase a student might ask, paired with a clear educational answer.
Respond ONLY with a valid JSON array (no markdown, no backticks, no commentary).
Each element must have exactly these keys:
"n" intent_name: unique snake_case identifier 60 chars, prefixed with the batch id given
"p" pattern: a PHP PCRE regex (use (?i) for case-insensitive) that matches the question
"r" response: a thorough but concise educational answer (25 sentences or a short structured list)
"c" category: the category string provided
Rules:
- Patterns must use \\b word boundaries; escape backslashes for JSON (\\b not \b)
- Patterns should NOT start with ^ or end with $ (they are substring matches)
- Responses must be factually accurate
- Do not duplicate intent names; every "n" must be unique within this batch
- Return exactly 20 intents
SYS;
/* ── insert helper ── */
$inserted = 0;
$skipped = 0;
$errors = 0;
function safe_insert(array $intent, string $batchCategory): void {
global $inserted, $skipped, $errors;
$name = trim($intent['n'] ?? '');
$pattern = trim($intent['p'] ?? '');
$response = trim($intent['r'] ?? '');
$category = trim($intent['c'] ?? $batchCategory);
if (!$name || !$pattern || !$response) { $errors++; return; }
if (strlen($name) > 64) $name = substr($name, 0, 64);
if (strlen($pattern) > 512) $pattern = substr($pattern, 0, 512);
if (strlen($response) < 30) { $skipped++; return; } // too short
try {
JarvisDB::execute(
'INSERT INTO kb_intents (intent_name, pattern, response_template, fact_category, action_type, priority, active)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
pattern=VALUES(pattern),
response_template=VALUES(response_template),
fact_category=VALUES(fact_category)',
[$name, $pattern, $response, $category, 'response', 5, 1]
);
$inserted++;
} catch (Exception $e) {
$errors++;
}
}
/* ── main generation loop ── */
$totalBatches = count($runBatches);
foreach ($runBatches as $idx => $batch) {
$num = $idx + 1;
log_line("Batch {$num}/{$totalBatches}: {$batch['topic']}");
$user = "Generate 20 KB intents for the topic: {$batch['topic']}.\n"
. "Subtopics to cover: {$batch['desc']}.\n"
. "Prefix every intent_name with \"{$batch['id']}_\".\n"
. "Category string to use: \"{$batch['category']}\".";
$raw = groq($SYSTEM, $user, 5000);
if ($raw === null) {
log_line(" ✗ API call failed after retries skipping batch.");
$errors += 20;
sleep(8);
continue;
}
// Strip markdown code fences if model added them
$raw = preg_replace('/^```(?:json)?\s*/m', '', $raw);
$raw = preg_replace('/^```\s*/m', '', $raw);
$raw = trim($raw);
// Extract JSON array; fall back to partial recovery for truncated responses
$items = null;
if (preg_match('/\[\s*\{.*\}\s*\]/s', $raw, $m)) {
$items = json_decode($m[0], true);
if (!is_array($items)) {
log_line(" ✗ JSON parse failed skipping batch.");
$errors += 20; sleep(8); continue;
}
} else {
$start = strpos($raw, '[');
if ($start !== false) {
$partial = substr($raw, $start);
if (preg_match_all('/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/s', $partial, $objs) && !empty($objs[0])) {
$recovered = '[' . implode(',', $objs[0]) . ']';
$items = json_decode($recovered, true);
if (is_array($items) && count($items) > 0)
log_line(" ⚠ Truncated — recovered " . count($items) . " items.");
}
}
if (!is_array($items) || count($items) === 0) {
log_line(" ✗ No JSON array found — raw[0:120]: " . substr(str_replace("\n", ' ', $raw), 0, 120));
$errors += 20; sleep(8); continue;
}
}
$batchInserted = 0;
foreach ($items as $item) {
if (!is_array($item)) continue;
safe_insert($item, $batch['category']);
$batchInserted++;
}
log_line(" ✓ Parsed {$batchInserted} intents (running total inserted: {$inserted}).");
// Polite delay between API calls — Groq TPM limit needs ~8s between batches
if ($num < $totalBatches) sleep(8);
}
log_line("Generation complete. Inserted/updated: {$inserted} | Short/invalid skipped: {$skipped} | Errors: {$errors}");
/* ── cleanup phase ── */
log_line('Starting cleanup phase...');
// 1. Remove exact duplicate intent_names (keep the one with the longer response)
$dups = JarvisDB::query(
'SELECT intent_name, COUNT(*) AS cnt FROM kb_intents GROUP BY intent_name HAVING cnt > 1'
);
$dupsPruned = 0;
foreach ($dups as $dup) {
$rows = JarvisDB::query(
'SELECT id, LENGTH(response_template) AS rlen FROM kb_intents WHERE intent_name=? ORDER BY rlen DESC',
[$dup['intent_name']]
);
array_shift($rows);
foreach ($rows as $row) {
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$row['id']]);
$dupsPruned++;
}
}
log_line(" Duplicate intent_names pruned: {$dupsPruned}");
// 2. Remove intents with very short responses (< 40 chars)
$shortPruned = JarvisDB::execute(
"DELETE FROM kb_intents WHERE LENGTH(response_template) < 40 AND priority <= 5"
);
log_line(" Short-response rows pruned: {$shortPruned}");
// 3. Trim whitespace on all generated intents
JarvisDB::execute(
"UPDATE kb_intents SET
intent_name = TRIM(intent_name),
pattern = TRIM(pattern),
response_template = TRIM(response_template),
fact_category = TRIM(fact_category)
WHERE priority = 5"
);
log_line(' Whitespace trimmed on all generated intents.');
// 4. Fix and validate PCRE patterns — normalize then deactivate only truly broken ones
$all = JarvisDB::query('SELECT id, pattern FROM kb_intents WHERE priority=5');
$badPattern = 0;
$fixedPattern = 0;
foreach ($all as $row) {
$pat = normalize_pattern($row['pattern']);
if ($pat !== $row['pattern']) {
// Update to normalized form
JarvisDB::execute('UPDATE kb_intents SET pattern=?, active=1 WHERE id=?', [$pat, $row['id']]);
$fixedPattern++;
} elseif (@preg_match($pat, '') === false) {
JarvisDB::execute('UPDATE kb_intents SET active=0 WHERE id=?', [$row['id']]);
$badPattern++;
} else {
// Valid pattern — make sure it's active
JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE id=?', [$row['id']]);
}
}
log_line(" Patterns normalized: {$fixedPattern} | Bad PCRE deactivated: {$badPattern}");
// 5. Enable ALL remaining inactive intents (including pre-existing ones)
$reactivated = JarvisDB::execute('UPDATE kb_intents SET active=1 WHERE active=0 AND priority <= 5');
log_line(" Re-activated previously inactive intents: {$reactivated}");
// 6. Final stats
$stats = JarvisDB::single('SELECT COUNT(*) AS total, SUM(active) AS active FROM kb_intents');
log_line("Final KB Intents table: {$stats['total']} total, {$stats['active']} active.");
/* ── record last-run timestamp ── */
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_run', NOW(), 'local')
ON DUPLICATE KEY UPDATE fact_value=NOW(), updated_at=NOW()",
[]
);
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'batch_offset', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$nextOffset]
);
log_line("Next run will start at topic offset {$nextOffset}/{$totalTopics}.");
JarvisDB::execute(
"INSERT INTO kb_facts (category, fact_key, fact_value, host)
VALUES ('kb_generator', 'last_inserted', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[$inserted]
);
log_line('Done.');
+55
View File
@@ -479,3 +479,58 @@ CREATE TABLE IF NOT EXISTS `guardian_events` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Dump completed on 2026-06-29 23:15:44
CREATE TABLE IF NOT EXISTS `email_triage` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) NOT NULL,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`date_received` datetime DEFAULT NULL,
`category` varchar(32) NOT NULL DEFAULT 'info',
`priority` int(11) NOT NULL DEFAULT 3,
`summary` text DEFAULT NULL,
`draft_reply` text DEFAULT NULL,
`action_taken` varchar(32) NOT NULL DEFAULT 'none',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uq_msg` (`msg_id`),
KEY `idx_category` (`category`),
KEY `idx_action` (`action_taken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_actions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`msg_id` varchar(255) DEFAULT NULL,
`from_name` varchar(255) DEFAULT NULL,
`from_email` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`received_at` datetime DEFAULT NULL,
`suggested_title` varchar(255) DEFAULT NULL,
`suggested_date` date DEFAULT NULL,
`task_id` int(11) DEFAULT NULL,
`appointment_id` int(11) DEFAULT NULL,
`dismissed` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_dismissed` (`dismissed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `email_sent` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL DEFAULT 'gmail',
`to_email` varchar(255) NOT NULL,
`to_name` varchar(255) DEFAULT NULL,
`subject` varchar(500) DEFAULT NULL,
`body` text DEFAULT NULL,
`triage_id` int(11) DEFAULT NULL,
`status` varchar(32) NOT NULL DEFAULT 'sent',
`sent_at` timestamp NULL DEFAULT current_timestamp(),
`error` text DEFAULT NULL,
`message_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `idx_account` (`account`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# JARVIS backup — DB dump + all files needed to actually restore JARVIS, as tar.gz
# Fixed 2026-07-07: this only ever backed up the MySQL database. If this VM were
# lost, the DB alone is useless without the application code, the reactor daemon,
# its systemd unit, and the nginx site config — none of which were captured. Also
# fixed a typo ($SIYE -> $SIZE) that silently broke the size line in the log.
BACKUP_DIR="/var/backups/jarvis"
LOG="$BACKUP_DIR/backup.log"
LOCK="$BACKUP_DIR/backup.lock"
DB_NAME="jarvis_db"
DB_USER="jarvis_user"
DB_PASS="J4rv1s_Pr0t0c0l_2026!"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
OUTFILE="$BACKUP_DIR/jarvis_backup_${TIMESTAMP}.tar.gz"
TMPDIR=$(mktemp -d)
mkdir -p "$BACKUP_DIR"
touch "$LOCK"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup..." >> "$LOG"
cleanup() { rm -rf "$TMPDIR"; rm -f "$LOCK"; }
trap cleanup EXIT
if mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$TMPDIR/jarvis_db.sql" 2>>"$LOG"; then
mkdir -p "$TMPDIR/files/etc"
cp -a /var/www/jarvis "$TMPDIR/files/var-www-jarvis"
cp -a /opt/jarvis-arc "$TMPDIR/files/opt-jarvis-arc"
cp -a /etc/nginx/sites-enabled/jarvis "$TMPDIR/files/etc/nginx-site-jarvis" 2>>"$LOG"
cp -a /etc/systemd/system/jarvis-arc.service "$TMPDIR/files/etc/jarvis-arc.service" 2>>"$LOG"
crontab -l > "$TMPDIR/files/etc/root-crontab.txt" 2>>"$LOG"
tar -czf "$OUTFILE" -C "$TMPDIR" jarvis_db.sql files
SIZE=$(du -sh "$OUTFILE" | cut -f1)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: $(basename "$OUTFILE") ($SIZE)" >> "$LOG"
else
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: mysqldump failed" >> "$LOG"
exit 1
fi
find "$BACKUP_DIR" -name "jarvis_backup_*.tar.gz" -mtime +7 -delete
COUNT=$(ls "$BACKUP_DIR"/jarvis_backup_*.tar.gz 2>/dev/null | wc -l)
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Done. Files retained: $COUNT" >> "$LOG"
+159 -125
View File
@@ -41,16 +41,17 @@ DB_PORT = 3306
DB_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_NAME = "jarvis_db"
LOG_FILE = "/home/jarvis.orbishosting.com/logs/arc_reactor.log"
LOG_FILE = "/var/log/jarvis/arc_reactor.log"
POLL_INTERVAL = 3
HEARTBEAT_INTERVAL = 30
CLAUDE_API_KEY = "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"
CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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"
GROQ_API_KEY = "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"
GROQ_MODEL = "llama-3.3-70b-versatile"
OLLAMA_HOST = "http://10.48.200.210:11434"
OLLAMA_MODEL = "llama3.1:8b"
OLLAMA_VISION_MODEL = os.environ.get("OLLAMA_VISION_MODEL", "") # e.g. "llava" or "moondream" -- empty = disabled
GMAIL_USER = "myronblair@gmail.com"
GMAIL_PASS = "demsvdylwweacbcx"
@@ -129,18 +130,35 @@ async def handle_shell(payload: dict) -> dict:
# ═══════════════════════════════════════════════════════════════════════════════
async def llm_call(messages: list, provider: str = "claude", system: str = "") -> str:
if provider == "claude" and CLAUDE_API_KEY:
return await _claude_call(messages, system)
elif provider == "groq" and GROQ_API_KEY:
return await _groq_call(messages, system)
elif provider == "ollama":
return await _ollama_call(messages, system)
for p in ["groq", "ollama"]:
# Fixed 2026-07-07: previously, an explicitly-requested provider (e.g. "claude")
# called its API directly with no exception handling, so any failure (rate limit,
# depleted credits, outage) raised straight up instead of falling back to the other
# providers below. The fallback loop only ever ran for an unrecognized provider
# string, which never happens in practice — so callers requesting "claude" got zero
# real resilience. Now every explicit request tries its provider first, then falls
# through the remaining ones in order before giving up.
order = {"claude": ["claude", "groq", "ollama"],
"groq": ["groq", "ollama"],
"ollama": ["ollama"]}.get(provider, ["claude", "groq", "ollama"])
last_err = None
for p in order:
try:
return await llm_call(messages, p, system)
except Exception:
if p == "claude" and CLAUDE_API_KEY:
result = await _claude_call(messages, system)
elif p == "groq" and GROQ_API_KEY:
result = await _groq_call(messages, system)
elif p == "ollama":
result = await _ollama_call(messages, system)
else:
continue
if not result or not result.strip():
raise RuntimeError(f"{p} returned empty content")
return result
except Exception as e:
log.warning(f"[LLM] Provider {p} failed: {type(e).__name__}: {e}")
last_err = e
continue
raise RuntimeError("All LLM providers failed")
raise RuntimeError(f"All LLM providers failed (last error: {last_err})")
async def _claude_call(messages: list, system: str = "") -> str:
payload = {"model": CLAUDE_MODEL, "max_tokens": 4096, "messages": messages}
@@ -155,35 +173,105 @@ async def _claude_call(messages: list, system: str = "") -> str:
raise RuntimeError(f"Claude API error {resp.status}: {data.get('error',{}).get('message','')}")
return data["content"][0]["text"]
def _parse_groq_reset(val: str) -> float:
"""Parse Groq's Go-style duration strings ('229ms', '2.5s', '2m52.8s') into seconds."""
import re as _re
if not val:
return 0.0
total = 0.0
for num, unit in _re.findall(r'([\d.]+)(ms|s|m|h)', val):
n = float(num)
total += n/1000 if unit == 'ms' else n*60 if unit == 'm' else n*3600 if unit == 'h' else n
return total
async def _groq_call(messages: list, system: str = "") -> str:
# Added 2026-07-07: retry once on 429 (rate limit) using Groq's own reset-time
# header, capped short — this account's tier has a low 12k-tokens/min ceiling that
# gmail_triage alone can exhaust, so transient contention here is common and often
# clears in well under a second. Capped so a genuinely long reset just falls
# through to Ollama instead of blocking the whole compose flow.
all_msgs = ([{"role": "system", "content": system}] if system else []) + messages
payload = {"model": GROQ_MODEL, "messages": all_msgs, "max_tokens": 4096}
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
async with aiohttp.ClientSession() as session:
async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload,
headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp:
data = await resp.json()
if resp.status != 200:
raise RuntimeError(f"Groq error {resp.status}")
return data["choices"][0]["message"]["content"]
for attempt in range(2):
async with aiohttp.ClientSession() as session:
async with session.post("https://api.groq.com/openai/v1/chat/completions", json=payload,
headers=headers, timeout=aiohttp.ClientTimeout(total=45)) as resp:
if resp.status == 429 and attempt == 0:
wait = min(_parse_groq_reset(resp.headers.get("x-ratelimit-reset-tokens", "")) or
_parse_groq_reset(resp.headers.get("x-ratelimit-reset-requests", "")) or 2.0, 8.0)
log.info(f"[LLM] Groq 429 — retrying in {wait:.1f}s")
await asyncio.sleep(wait)
continue
data = await resp.json()
if resp.status != 200:
raise RuntimeError(f"Groq error {resp.status}")
return data["choices"][0]["message"]["content"]
async def _ollama_call(messages: list, system: str = "") -> str:
prompt = (system + "\n\n" if system else "") + "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages)
async with aiohttp.ClientSession() as session:
async with session.post(f"{OLLAMA_HOST}/api/generate", json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
timeout=aiohttp.ClientTimeout(total=30)) as resp:
timeout=aiohttp.ClientTimeout(total=90)) as resp: # bumped from 30s 2026-07-07: cold model loads on this CPU-only host alone took 15s+ in testing, leaving no room for actual generation
data = await resp.json()
# Fixed 2026-07-07: this had no error checking at all — if the model wasn't
# pulled (or any other Ollama-side error), the API returns {"error": "..."}
# with no "response" key, and this silently returned "" instead of raising,
# which fooled llm_call()'s fallback into treating it as a real success.
if "error" in data:
raise RuntimeError(f"Ollama error: {data['error']}")
return data.get("response", "")
async def _ollama_vision_call(image_b64: str, prompt: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{OLLAMA_HOST}/api/generate",
json={"model": "llava:7b", "prompt": prompt, "images": [image_b64], "stream": False},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
data = await resp.json()
return data.get("response", "").strip()
# -- VISION CALL ----------------------------------------------------------
async def _vision_call(image_b64: str, prompt: str) -> tuple:
"""
Vision provider cascade: Claude -> Ollama vision model -> graceful fallback.
Returns (analysis: str, provider: str).
To enable a local vision model: set OLLAMA_VISION_MODEL env var (e.g. "llava").
"""
# 1. Claude (primary)
if CLAUDE_API_KEY:
try:
import anthropic as _anthropic
_client = _anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
_msg = await _client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
]}],
)
text = _msg.content[0].text if _msg.content else ""
log.info("[VISION] Claude vision OK")
return text, "claude"
except Exception as e:
log.warning(f"[VISION] Claude failed ({e}), trying next provider")
# 2. Ollama vision model (if configured via OLLAMA_VISION_MODEL env var)
if OLLAMA_VISION_MODEL:
try:
async with aiohttp.ClientSession() as _sess:
_payload = {"model": OLLAMA_VISION_MODEL, "prompt": prompt,
"images": [image_b64], "stream": False}
async with _sess.post(f"{OLLAMA_HOST}/api/generate", json=_payload,
timeout=aiohttp.ClientTimeout(total=120)) as _resp:
if _resp.status == 200:
_data = await _resp.json()
text = _data.get("response", "")
log.info(f"[VISION] Ollama vision OK ({OLLAMA_VISION_MODEL})")
return text, f"ollama/{OLLAMA_VISION_MODEL}"
raise RuntimeError(f"Ollama HTTP {_resp.status}")
except Exception as e:
log.warning(f"[VISION] Ollama vision failed ({e})")
# 3. No vision provider available
msg = ("[Vision unavailable] Claude credits depleted and no local vision model configured. "
"To enable: pull a vision model on Ollama (e.g. 'ollama pull llava') then set "
"OLLAMA_VISION_MODEL=llava in the jarvis-arc systemd environment.")
log.warning("[VISION] All vision providers unavailable")
return msg, "none"
async def handle_llm(payload: dict) -> dict:
message = payload.get("message", "")
@@ -668,44 +756,19 @@ async def handle_screenshot(payload: dict) -> dict:
height = result.get("height", 0)
file_size = result.get("file_size", 0)
# Run vision analysis if we have an image — llava first, Claude fallback
# Run Claude vision analysis if we have an image
analysis = ""
provider_used = ""
if do_analyze and image_b64:
try:
analysis = await _ollama_vision_call(image_b64, analyze_prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": analyze_prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
log.info(f"[VISION] Claude fallback analysis complete ({len(analysis)} chars)")
except Exception as e2:
log.warning(f"[VISION] Claude fallback also failed: {e2}")
analysis = f"Vision analysis unavailable: {e2}"
analysis, provider_used = await _vision_call(image_b64, analyze_prompt)
log.info(f"[VISION] Analysis complete via {provider_used} ({len(analysis)} chars)")
elif do_analyze and not image_b64 and result.get("snapshot_type") == "text":
# Text-only sysinfo snapshot — summarize with Ollama
# Text-only sysinfo snapshot — summarize with LLM
try:
snap_text = json.dumps(result, indent=2)[:3000]
prompt = f"Summarize this server system snapshot for JARVIS. Highlight any concerns:\n\n{snap_text}"
analysis = await llm_call([{"role": "user", "content": prompt}], "ollama")
provider_used = "ollama"
analysis = await llm_call([{"role": "user", "content": prompt}], "groq")
provider_used = "groq"
except Exception as e:
analysis = f"Analysis unavailable: {e}"
@@ -758,41 +821,9 @@ async def handle_vision(payload: dict) -> dict:
if not image_b64:
raise ValueError("No image data provided")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname} provider={provider}")
log.info(f"[VISION] Analysis: screenshot_id={screenshot_id} agent={hostname}")
analysis = ""
provider_used = provider
if provider == "ollama" or provider == "llava":
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
else:
# Default: llava first, Claude fallback
try:
analysis = await _ollama_vision_call(image_b64, prompt)
provider_used = "ollama:llava"
log.info(f"[VISION] llava analysis complete ({len(analysis)} chars)")
except Exception as e:
log.warning(f"[VISION] llava failed: {e} — falling back to Claude")
try:
import anthropic
client = anthropic.AsyncAnthropic(api_key=CLAUDE_API_KEY)
msg = await client.messages.create(
model="claude-opus-4-8-20251101",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64",
"media_type": "image/png", "data": image_b64}},
{"type": "text", "text": prompt},
],
}],
)
analysis = msg.content[0].text if msg.content else ""
provider_used = "claude"
except Exception as e2:
raise RuntimeError(f"Vision analysis failed (llava: {e}, claude: {e2})")
analysis, provider_used = await _vision_call(image_b64, prompt)
# Update stored screenshot if we have an ID
if screenshot_id:
@@ -1053,7 +1084,7 @@ async def guardian_loop() -> None:
"for Myron. Be direct about severity and what action to take. "
"No markdown, no headers."
)
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "ollama")
ai_msg = await llm_call([{"role": "user", "content": ai_prompt}], "groq")
# Update the most recent guardian event with AI analysis
await db_execute(
"""UPDATE guardian_events SET ai_analysis=%s
@@ -1082,7 +1113,7 @@ async def _guardian_inject_chat(message: str) -> None:
"""Write a proactive JARVIS message into the conversations table so the HUD picks it up."""
try:
await db_execute(
"""INSERT INTO conversations (session_id, role, content, created_at)
"""INSERT INTO conversations (session_id, role, message, created_at)
VALUES ('guardian', 'assistant', %s, NOW())""",
(message,)
)
@@ -1096,7 +1127,7 @@ async def handle_sitrep(payload: dict) -> dict:
payload: { detail: brief|full, provider: groq }
"""
detail = payload.get("detail", "full")
provider = payload.get("provider", "ollama")
provider = payload.get("provider", "groq")
log.info(f"[GUARDIAN] SITREP requested (detail={detail})")
@@ -1837,7 +1868,7 @@ Keep the tone confident and action-oriented. Format with clear sections. Under 4
# Store in conversations so HUD can surface it
await db_execute(
"INSERT INTO conversations (session_id, role, content, created_at) VALUES ('guardian','assistant',%s,NOW())",
"INSERT INTO conversations (session_id, role, message, created_at) VALUES ('guardian','assistant',%s,NOW())",
(review_text,)
)
@@ -2240,31 +2271,6 @@ async def lifespan(app: FastAPI):
log.info(f"◈ JARVIS Arc Reactor v{VERSION} starting on {HOST}:{PORT}")
await get_pool()
await db_execute("UPDATE arc_status SET started_at=NOW(), last_heartbeat=NOW(), version=%s WHERE id=1", (VERSION,))
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_config (
id INT AUTO_INCREMENT PRIMARY KEY,
key_name VARCHAR(64) NOT NULL,
value VARCHAR(255) NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_key (key_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
await db_execute("""CREATE TABLE IF NOT EXISTS guardian_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info',
agent_id VARCHAR(64) NOT NULL DEFAULT '',
hostname VARCHAR(128) NOT NULL DEFAULT '',
metric VARCHAR(64) NOT NULL DEFAULT '',
value FLOAT NOT NULL DEFAULT 0,
threshold FLOAT NOT NULL DEFAULT 0,
message TEXT NOT NULL,
ai_analysis TEXT NOT NULL DEFAULT '',
acknowledged TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_severity (severity),
KEY idx_ack (acknowledged),
KEY idx_created (created_at),
KEY idx_agent (agent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci""")
asyncio.create_task(job_poller())
asyncio.create_task(heartbeat_loop())
asyncio.create_task(guardian_loop())
@@ -2784,13 +2790,13 @@ async def guardian_chat_events(since: str = ""):
"""Return proactive guardian messages injected into conversations."""
if since:
rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations "
"SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' AND created_at > %s ORDER BY created_at ASC",
(since,)
)
else:
rows = await db_fetchall(
"SELECT id, content AS message, created_at FROM conversations "
"SELECT id, message, created_at FROM conversations "
"WHERE session_id='guardian' ORDER BY created_at DESC LIMIT 10"
)
return rows or []
@@ -2825,5 +2831,33 @@ async def comms_sent_delete(sent_id: int):
await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,))
return {"ok": True}
@app.post("/comms/sent/{sent_id}/send")
async def comms_sent_send(sent_id: int):
"""
Added 2026-07-07: sends a previously-composed queued draft. Compose always
created a draft (status='queued') for review, but there was no action anywhere
to actually send one this closes that gap. handle_send_email() records its own
fresh row in email_sent (sent/failed), so the original queued draft row is removed
here once we've handed its content off, rather than leaving a stale duplicate.
"""
row = await db_fetchone(
"SELECT account, to_email, to_name, subject, body, triage_id FROM email_sent WHERE id=%s AND status='queued'",
(sent_id,)
)
if not row:
raise HTTPException(status_code=404, detail="Queued draft not found")
result = await handle_send_email({
"account": row["account"],
"to_email": row["to_email"],
"to_name": row["to_name"],
"subject": row["subject"],
"body": row["body"],
"triage_id": row["triage_id"],
})
await db_execute("DELETE FROM email_sent WHERE id=%s", (sent_id,))
return result
if __name__ == "__main__":
uvicorn.run("reactor:app", host=HOST, port=PORT, log_level="info", access_log=False)
@@ -1,5 +1,5 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-06-18
**Last Updated:** 2026-07-07 (WAN IP change + FortiGate VIP update; JARVIS auto-deploy pipeline fully restored; admin panel fixes — Works tab, Site Health, Email Intelligence/Outbox; backup systems audited and fixed to include actual data, not just DB/config; new Blair HQ Backup Downloads feature)
**Owner:** Myron Blair — myronblair@outlook.com
---
@@ -17,6 +17,8 @@
10. [Backup Systems](#10-backup-systems)
11. [SSH Quick Reference](#11-ssh-quick-reference)
12. [Critical Credentials Master List](#12-critical-credentials-master-list)
13. [Git & Repository Management](#13-git--repository-management)
14. [Network Equipment & Client Device Inventory](#14-network-equipment--client-device-inventory)
---
@@ -29,21 +31,23 @@ INTERNET
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (6 sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (7 sites)
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
HOME NETWORK (FortiGate router at 10.48.200.1)
WAN: 97.154.109.245 (dynamic, DDNS: orbisne.fortiddns.com)
WAN: 97.245.77.197 (dynamic, DDNS: orbisne.fortiddns.com — changed 2026-07-07,
previously 97.154.207.5, and 97.154.109.245 before that. All 35 FortiGate
VIPs' extip updated to match on each change — see Section 9 for the full list.)
├─► PVE1 Proxmox 10.48.200.90 (primary hypervisor)
│ ├── VM 101 10.48.200.97 Home Assistant
│ ├── VM 112 10.48.200.33 Jellyfin
│ ├── VM 113 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 103 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
│ ├── VM 118 10.48.200.18 Homebridge
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
│ ├── VM 210 10.48.200.210 Ollama (local LLM) (local LLM)
│ └── CT110 10.48.200.19 WireGuard exit container
│ ├── VM 106 10.48.200.210 Ollama (local LLM + vision) — llama3.1:8b, llava:7b
│ └── CT110 10.48.200.19/.67 WireGuard exit container (disabled at boot 2026-07-06, legacy/unused)
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
│ └── VM 302 10.48.200.99 NetworkBackup
@@ -56,10 +60,23 @@ HOME NETWORK (FortiGate router at 10.48.200.1)
├─► Yealink T57W 10.48.200.83 — Ext 1003 (Kitchen)
└─► Yealink T57W 10.48.200.85 — Ext 1004 (Master Bedroom)
FortiGate Port Forwards:
orbisne.fortiddns.com:8006 → PVE1:8006 (Proxmox web UI)
orbisne.fortiddns.com:8123 → HA:8123 (Home Assistant)
orbisne.fortiddns.com:22 → HA VM:22 (SSH — key only, unreliable)
FortiGate Port Forwards (VIP list confirmed 2026-07-07 via `show firewall vip` — this
replaces the earlier partial/approximate list; all extip values = current WAN IP above):
homeassistant-8123 → 10.48.200.97:8123 (Home Assistant)
proxmox1-8006 → 10.48.200.90:8006 (PVE1 web UI)
proxmox2-8007to8006 → 10.48.200.91:8006 (PVE2 web UI, ext 8007)
proxmoxbku-8007to8008 → 10.48.200.92:8007 (ext 8008)
reolink-9000/1935/554/8000 → 10.48.200.95 (4 VIPs, one per port)
proxmox-ssh-claude-connection → 10.48.200.29:22
jarvis-8443 → 10.48.200.211:80 (JARVIS — working forward, use this for external HTTP to JARVIS)
jarvis-1972 → 10.48.200.211:80 (legacy port, confirmed working again 2026-07-07 after the WAN IP/VIP update — earlier notes calling this "dead" were from before that fix)
tunnel-51820 / Wireguard-CT110 → 10.48.200.67:51821 (UDP, WireGuard — 2 VIPs, same target)
dns-53 → 10.48.200.90:53
bbs-23 → 10.48.200.112:23 (SynchroNet BBS)
NOVACPX-* (18 VIPs) → 10.48.200.110 or .200 — HTTP/HTTPS/user/reseller/admin/webmail/SSH/FTP/SMTP/POP3/IMAP/DNS ports for NovaCPX hosting panel
NPM-HTTP / NPM-HTTPS → 10.48.200.200:80/443 (Nginx Proxy Manager, VM 105)
No VIP maps external port 80 directly to JARVIS — use jarvis-8443 (or the working
jarvis-1972) for external HTTP access instead.
```
---
@@ -73,7 +90,7 @@ FortiGate Port Forwards:
| **OS** | Ubuntu 22.04 LTS |
| **Panel** | CyberPanel (OpenLiteSpeed) |
| **SSH** | `ssh root@165.22.1.228` — password: `Gonewalk1974!@#` |
| **Purpose** | All public websites (6 sites) — webhook deploy for websites |
| **Purpose** | All public websites (7 sites) — webhook deploy for websites |
**Key Paths:**
- All sites: `/home/<domain>/public_html/`
@@ -166,6 +183,25 @@ qm guest exec <VMID> -- bash -c "cmd" # run command inside VM (requires QEMU ag
## 4. ON-PREMISE — VIRTUAL MACHINES
### VM 100 — SynchroNet (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.112 |
| **OS** | Windows |
| **Purpose** | SynchroNet BBS (bulletin board system) |
| **Note** | VM is named "SynchroNet-50" in Proxmox but its real IP is `.112`, not `.50``.50` is an unrelated Raspberry Pi 5 hobby device (see Section 14). Confirmed 2026-07-06. |
---
### VM 105 — Nginx Proxy Manager (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.200 |
| **Purpose** | Reverse proxy management (NPM) |
| **Note** | Confirmed 2026-07-06; VM name `NPM-200` matches its IP correctly (unlike SynchroNet/Ollama above). |
---
### VM 101 — Home Assistant (PVE1)
| Field | Value |
|-------|-------|
@@ -188,11 +224,12 @@ qm guest exec <VMID> -- bash -c "cmd" # run command inside VM (requires QEMU ag
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.33 |
| **OS** | Ubuntu 22.04 LTS |
| **OS** | Ubuntu 24.04.4 LTS (verified 2026-07-06; previously logged as 22.04 — reinstalled/upgraded at some point) |
| **SSH** | `ssh root@10.48.200.33` — password: `Joker1974!!!` (enabled 2026-06-14) |
| **Web UI** | `http://10.48.200.33:8096` |
| **Web UI** | `http://10.48.200.33:8096` (Jellyfin 10.11.11) |
| **Purpose** | Media streaming server — Movies and TV shows |
| **JARVIS Agent** | Not yet installed |
| **JARVIS Agent** | `jarvis-agent.service` running (verified 2026-07-06) |
| **Remote access** | Tailscale installed, node `jellyfin-112` at `100.81.145.48` — used for off-LAN streaming access |
**Media Libraries:**
- Movies: `/mnt/mediastack/movies` — NFS from MediaStack (10.48.200.35:/media/movies)
@@ -200,12 +237,12 @@ qm guest exec <VMID> -- bash -c "cmd" # run command inside VM (requires QEMU ag
**NFS chain:** Jellyfin → MediaStack → Synology NAS (`/volume1/video/movies` and `/volume1/video/tv`)
**Admin token:** `7c0ccf78b91d4b5bafa607f585f24f2d`
**Admin token:** `635142c218f2457e813a2de8dec75b05` (regenerated 2026-07-06 — previous token `7c0ccf78...` had gone stale/invalid. The account's real username is **admin**, not myron — confirmed via the `Users` table in `/var/lib/jellyfin/data/jellyfin.db`; it's hidden from the public login list, which is why `/Users/Public` returns empty.)
**If library scan needed:**
```bash
curl -X POST "http://10.48.200.33:8096/Library/Refresh" \
-H "X-Emby-Token: 7c0ccf78b91d4b5bafa607f585f24f2d"
-H "X-Emby-Token: 635142c218f2457e813a2de8dec75b05"
```
**If NFS stale after MediaStack changes:**
@@ -216,14 +253,15 @@ mount /mnt/mediastack/movies && mount /mnt/mediastack/tv
---
### VM 113 — MediaStack (PVE1)
### VM 103 — MediaStack (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.35 |
| **OS** | Ubuntu 24.04 LTS |
| **OS** | Ubuntu 24.04.4 LTS |
| **SSH** | Via PVE1: `ssh -i /root/.ssh/id_rsa root@10.48.200.35` (no direct access from DO) |
| **Purpose** | Automated media download pipeline + NFS server to Jellyfin |
| **JARVIS Agent** | ID: `MediaStack_2c00b1b8` |
| **Not Docker** | Despite the name, all services below run bare-metal via systemd, not docker-compose |
**Services:**
| Service | Port | Login | API Key |
@@ -235,8 +273,7 @@ mount /mnt/mediastack/movies && mount /mnt/mediastack/tv
**All services run as root** — required by Synology NFS ACL (only root can write).
**VPN:** NordVPN — `nordlynx` WireGuard interface — exit IP 181.214.226.188 (US Dallas)
All download traffic exits via NordVPN. If downloads stall, check: `ip rule show` for rules 32764/32765.
**VPN:** NordVPN — `nordlynx` WireGuard interface — exit IP rotates (US Dallas servers), not a fixed IP (previous "181.214.226.188" was just a snapshot, not stable). All download/general traffic exits via NordVPN with LAN traffic exempted (Kill Switch + Firewall + LAN Discovery all `enabled` as of 2026-07-06 — see Section 9 for the full incident/fix history). If downloads stall, check `nordvpn status` first, then `ip rule show` for rules 32764/32765/table 205.
**Media Flow:**
```
@@ -305,17 +342,20 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
---
### VM 210 — Ollama Local LLM (PVE1)
### VM 106 — Ollama Local LLM + Vision (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.210 |
| **OS** | Ubuntu (cloud image) |
| **SSH** | `ssh myron@10.48.200.95` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Local AI inference — runs llama3.2 model for JARVIS Tier 1 chat |
| **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` (also reachable as `ssh myron@10.48.200.210` — password `Joker1974!`, then `sudo`). VM's name is `Ollama-95` but its real IP is `.210`, not `.95` — a prior version of this doc had a stray SSH line pointed at `.95` (nothing listens there); confirmed 2026-07-06 that `.210` is correct. |
| **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
| **Models** | `llama3.1:8b` (chat/Tier 1), `llava:7b` (vision cascade) |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
**Vision cascade:** Arc Reactor calls Claude first; if Claude credits depleted, falls back to llava:7b via Ollama.
Vision is enabled via: `/etc/systemd/system/jarvis-arc.service.d/vision.conf``OLLAMA_VISION_MODEL=llava:7b`
---
@@ -336,6 +376,7 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
| **IP** | 10.48.200.19 / 10.48.200.67 |
| **Purpose** | Legacy WireGuard exit tunnel to DO (10.200.0.4 via wg-exit) — currently NOT used by MediaStack/Jellyfin |
| **Note** | MediaStack uses NordVPN directly; Jellyfin uses wg1 peer on MediaStack for NFS only |
| **2026-07-06 incident** | MediaStack's `wg0` client for this tunnel (`/etc/wireguard/wg0.conf`) had a `PostUp` hook installing its own iptables kill-switch (`REJECT` all output not via `wg0` or marked `51820`), and `wg-quick@wg0.service` was still **enabled at boot** on MediaStack despite this tunnel being unused — this caused a full LAN/SSH lockout to MediaStack the moment that service came up. **Disabled `wg-quick@wg0` at boot on MediaStack** to prevent recurrence; the tunnel itself and CT110 are untouched. See NordVPN section below for the related (separate) LAN Discovery bug found in the same incident. |
---
@@ -349,7 +390,7 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
| **DSM Web UI** | `http://10.48.200.249:5000` |
| **Purpose** | Primary media and download storage |
**NFS Share:** `/volume1/video` (exported to MediaStack only)
**NFS Share:** `/volume1/video` and its subpaths `/volume1/video/movies`, `/volume1/video/tv` — all exported to MediaStack (10.48.200.35) only, per `/etc/exports` on the NAS (verified 2026-07-06)
**Directory structure:**
```
@@ -368,14 +409,14 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
All sites are at `/home/<domain>/public_html/` on DO (165.22.1.228).
**Auto-deploy:** Push to `main` on GitHub → webhook → server pulls in ~1 min.
**GitHub PAT:** `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` (expires ~2026-08-20)
**GitHub PAT:** `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` (rotated 2026-07-05 — old PAT `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` was found exposed in `.git/config` on all 6 original sites and must be treated as compromised/revoked)
---
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value |
|-------|-------|
| **URL** | http://jarvis.orbishosting.com:1972 |
| **URL** | http://jarvis.orbishosting.com (port 80 — old `:1972` reference was wrong, corrected 2026-07-04) |
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
| **GitHub** | `myronblair/jarvis` |
| **Login** | `myron / Joker1974!!!` |
@@ -385,6 +426,24 @@ See Section 7 for full JARVIS details.
---
### worktracking.orbishosting.com — ChuckCo Time Keeper
| Field | Value |
|-------|-------|
| **URL** | https://worktracking.orbishosting.com |
| **Path** | `/home/worktracking.orbishosting.com/public_html/` |
| **GitHub** | `myronblair/chucko` (private) |
| **Gitea** | `myron/chucko` (pull-mirror of GitHub) |
| **Local clone** | `C:\Users\myron\repos\chucko` on admin Windows machine |
| **Purpose** | Work-tracking app for a flat-rate 5-day (FriThu) work week — self-reported hours via personal secret-URL tokens (no login), single shared admin password, phone-friendly screenshot pages for texting workers/payer |
| **Admin URL** | `https://worktracking.orbishosting.com/admin/login.php` — password `Joker1974!!!` |
| **DB** | `workt_track_db` / `workt_track_user` / `ZWCNMRP2N5NVPsghmve5aRS9` |
| **Linked from Blair HQ** | `web.orbishosting.com` dashboard's "Websites" card has direct links to the admin login and the all-workers overview page (site-wide token `972f82cbf7832fdb2cffcdcc84129a4af69e30bd`) |
| **Note** | Built 2026-07-05. `includes/config.php` (DB creds, admin password hash, site token) lives outside `public_html`/webroot and is intentionally NOT in the git repo. |
| **Admin login rate limiting** | Added 2026-07-06 — `login_attempts` table (`ip_address`, `attempts`, `last_attempt`) in `workt_track_db`; 5 failed attempts locks that IP out for 15 minutes. |
| **Code review (2026-07-06)** | 5 findings fixed and deployed: `w.php` mark_paid now rejects any `week_start` that isn't the true current week (was trusting client input); `admin/worker.php` no longer double-HTML-escapes the page title; `all.php` now shows a Paid/Unpaid badge per worker for the displayed week; `s.php`/`p.php`/`all.php` validate the `week` param before building dates (malformed input used to throw an uncaught DateTime exception); admin login rate-limited (see above). |
---
### tomsjavajive.com — Tom's Java Jive
| Field | Value |
|-------|-------|
@@ -466,13 +525,80 @@ See Section 7 for full JARVIS details.
---
### Code review pass — all 4 DO-hosted business sites (2026-07-06)
Full security/correctness review of tomsjavajive.com, tomtomgames.com, parkerslingshotrentals.com, epictravelexpeditions.com (orbishosting.com apex and orbis.orbishosting.com excluded — not live/do-not-touch per earlier note). 10 findings, all fixed, tested, committed, and pushed to each site's `main` branch same day.
**Critical (live exploitable, now fixed):**
- **tomsjavajive.com `api/orders.php`** had a literal `// Admin check would go here` comment — anyone who knew/found an `order_id` could silently change any order's status (cancelled/delivered/refunded) or overwrite tracking numbers, and read another customer's full order (name, email, address, items), with zero auth. Fixed: `update_status` now requires `AdminAuth::isLoggedIn()`; the `GET ?id=` lookup now requires admin or the order's own customer.
- **parkerslingshotrentals.com `uploads/`** — customer driver's license and insurance-card photos were directly downloadable with no login at all (the `Order deny,allow`/`Require all denied` pattern in `uploads/.htaccess` doesn't work on this OpenLiteSpeed setup, same class of gotcha as the `.git` exposure found earlier). Verified live via a throwaway test file before fixing. Fixed with the working `RewriteRule .* - [F,L]` pattern in both the nested `uploads/.htaccess` and the root `.htaccess`**required an actual `systemctl restart lshttpd`** to take effect (touching `/usr/local/lsws/cgid` alone, which only prevents the cron's own restart trigger, was NOT sufficient for a *new* rewrite rule to be picked up — worth remembering for future `.htaccess` changes on this server).
**High (fixed):**
- **tomsjavajive.com `admin/orders.php`** — the exact "same named PDO param reused twice" bug that already bit `awardPoints()` had recurred in the order search box (`:search` bound once, referenced 3 times), causing a fatal `SQLSTATE[HY093]` on every admin search. Fixed with distinct `:search1`/`:search2`/`:search3`.
- **tomtomgames.com `admin/index.php`** — stored XSS: `renderGamerOverview()` inserted username/alias/email into `innerHTML` without the `escHtmlA()` helper used correctly everywhere else in the same file. Alias has no character restriction, so a malicious alias could execute script in an admin's session the moment they open that user's profile. Fixed.
**Medium (fixed):**
- **tomtomgames.com `includes/square.php`** (untracked by git — lives outside `public_html`, fix deployed to the server only) — `charge()`/`refund()` generated a fresh `uniqid()` idempotency key on every call, defeating Square's duplicate-protection entirely. Fixed: keyed off `md5(source_id)` for charges and `md5(payment_id . amount)` for refunds, so retries/double-clicks are recognized as duplicates.
- **parkerslingshotrentals.com `contact.php`** — booking availability check + insert had no locking, allowing a double-booking race under concurrent submissions; the deposit-hold idempotency key was suffixed with `time()` (changes every second, so retries aren't deduped). Fixed: wrapped the check+insert in a MySQL `GET_LOCK`/`RELEASE_LOCK` pair scoped to the requested date range, and made the idempotency key stable (`{ref}-dep`, no time suffix).
- **epictravelexpeditions.com `api/config.php`** — DB credentials, JWT secret, admin password hash, and mail API key sat in plaintext inside the webroot (protected only by an `.htaccess` rewrite rule, unlike every sibling site where secrets already live outside `public_html`). Relocated to `/home/epictravelexpeditions.com/api-secrets.php` (was already gitignored, so no git history exposure). `.git` itself was already correctly relocated to `git-data/` (just a 49-byte pointer file in the webroot, blocked by `.htaccess`) — no action needed there.
**Low (fixed):**
- **tomtomgames.com `api/purchase.php`**`logActivity()` referenced undefined `$paymentMethod`/`$amountDollars` (should be `$method`/`$priceCents`) in two places, producing PHP warnings and blank values in the purchase audit log for every transaction. Fixed.
- **epictravelexpeditions.com `api/api/testimonials.php`** — the public image-upload endpoint (no login required, by design) had no rate limiting, allowing storage/bandwidth abuse via scripted repeat uploads. Added a `upload_rate_limits` table + per-IP cap (5 uploads/hour).
---
## 7. JARVIS AI SYSTEM
**URL:** http://jarvis.orbishosting.com:1972
**URL:** http://jarvis.orbishosting.com
**Files:** `/var/www/jarvis/` on JARVIS VM (PVE1 VM 211 — 10.48.200.211, 8 cores, 16GB RAM)
**DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
**Login:** `myron / Joker1974!!!`
**Admin portal:** http://jarvis.orbishosting.com:1972/admin
**Admin portal:** http://jarvis.orbishosting.com/admin
### Security hardening (2026-07-06 code review)
Full front-end + admin panel review found and fixed 8 issues, 2 of them live critical exposures:
- **INFRASTRUCTURE-REFERENCE.md was publicly downloadable with zero auth** (static nginx path bypassed the admin session check entirely). Fixed: file moved to `/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md` (owned `www-data:www-data`, mode 640, NOT under `public_html` so nginx never serves it directly), and the DOCS tab now downloads it via a new authenticated `docs_download` action in `admin/index.php` that gates on the existing `loggedIn()` check and streams the file with `readfile()`.
- **Two full backup copies of the 5000-line admin panel (`index.php.bak2`, `index.php.bak.<date>`) were sitting in `public_html/admin/` and downloadable with no auth**, exposing the entire admin source/attack surface. Relocated to `/root/jarvis-old-backups/` (no secrets were found in them, so no credential rotation was needed).
- `esc()` (the admin panel's JS HTML-escaper) doesn't escape `'`, so it doesn't protect values embedded inside a single-quoted JS string within an `onclick` attribute — HTML-decoding happens before the JS parser sees it. Added a proper `escJs()` helper (backslash + quote + newline escaping, then HTML-escape) and applied it to the Network/Alerts/Intents/Custom-News/Calendar-Feeds edit-modal `onclick` handlers, which were reachable by e.g. any device on the LAN setting a malicious DHCP/mDNS hostname.
- Same class of bug on the front-end dashboard (`assets/js/jarvis-app.js`, `assets/js/panels/jarvis-agents.js`): device names and news article titles/sources were inserted into `innerHTML` completely unescaped — a rogue LAN device or a malicious/compromised news feed could inject script that runs with the logged-in session. Added `escHtml()`/`escJs()` helpers directly in `jarvis-app.js` and applied them to device names, VM names, agent hostnames, and news content.
- `session.cookie_httponly` was **Off** server-wide (PHP default), meaning the actual session cookie — not just the app's own bearer token — was readable via `document.cookie` from any of the above XSS bugs. Fixed at the PHP-FPM level (`/etc/php/8.3/fpm/php.ini`): `session.cookie_httponly = 1`, `session.cookie_samesite = Lax`, `php8.3-fpm` restarted. Verified live: `Set-Cookie` now includes `HttpOnly; SameSite=Lax`.
- `api.php` had `Access-Control-Allow-Origin: *` — tightened to an explicit allow-list of the real JARVIS origin only, with `Access-Control-Allow-Credentials: true` only sent when the origin matches.
- Two admin actions (Arc Reactor restart/setup) called an undefined function `k()` instead of the real JSON responder `j()`, causing a PHP fatal error even though the underlying `systemctl` command still fired. Fixed (verified via direct API test — clean `{"ok":true,...}` response now).
- Calendar feed passwords were stored and returned in plaintext via `cal_feeds_list`'s `SELECT *`. Changed to return a `has_password` boolean instead of the raw password (the edit UI never actually displayed the password back anyway — write-only field, "leave blank to keep").
All fixes verified via direct API testing (SSH + curl through the login/action flow) since this doesn't have a staging environment. Pushed to `myronblair/jarvis` master, commit `24bc876`.
### Functional bugs fixed (2026-07-06)
- **"WEB HOST" card on the front dashboard always showed `--%`/offline.** Root cause: the DO server (165.22.1.228) never had the JARVIS monitoring agent installed — every other host in the fleet had one, this one didn't. Installed it with `curl -sk http://10.48.200.211/install-agent.sh | bash -s jarvis-do linux` (hostname arg `jarvis-do` + the DO server's actual machine hostname `orbis` produces the expected `agent_id=jarvis-do_orbis` that `do_server.php` queries for). Also found and fixed two secondary issues hit along the way: the agent's config pointed at a **dead/stale Tailscale peer** (`jarvis-211`, 100.77.178.42, offline 9+ days) instead of the current active one (`jarvis-211-1`, 100.78.153.71) — likely left over from a VM Tailscale re-auth at some point; and a **stale cached API key** in `/var/lib/jarvis-agent/state.json` from a registration attempt that never actually completed server-side, which had to be deleted to force a clean re-registration. Verified live: `do_server` field in `/api/do` now returns real `cpu`/`mem`/`disk`/`online:true` instead of an empty array.
- **"WEBSITES" list (part of the same JARVIS SERVER panel) was always empty**, and the KB intent generator's 4-hour "don't run again too soon" guard was potentially never actually throttling correctly. Root cause, found while investigating the above: `api/config.php` sets `date_default_timezone_set('America/Chicago')`, and several places compute "how old is this DB timestamp" via PHP's `time() - strtotime($mysqlDatetimeString)`. Since MySQL's `NOW()`/stored datetimes are naive UTC strings, `strtotime()` under a non-UTC default timezone misinterprets them as being in Chicago time, which throws every such comparison off by the UTC offset (5-6 hours) — in this case making `facts_collector.php`'s freshness gate for the `sites` (and incidentally `proxmox`/`ollama`) categories always look artificially fresh, so the site-health checks that populate the WEBSITES list stopped actually running. Fixed in `facts_collector.php`'s `$fresh()` helper and `kb_intent_generator.php`'s run-guard by moving the elapsed-time comparison entirely into SQL (`updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)`), which sidesteps PHP timezone handling altogether. Also fixed a leftover cosmetic label (`do_server.php`) still showing `jarvis.orbishosting.com:1972` from before the JARVIS port fix.
- **Note for future work**: the `time() - strtotime($dbTimestamp)` anti-pattern appears in a couple of other files (`chat.php`, `email.php`, `planner.php`) but only for *display formatting* of dates, not elapsed-time threshold checks — lower priority, not fixed in this pass, but worth a look if any displayed timestamps look off by a few hours.
### Major fixes (2026-07-07)
**`/var/www/jarvis` finally became a real git repo.** It had no `.git` at all — the actual live admin panel had diverged completely from `myronblair/jarvis` on GitHub (the 2026-07-06 security review above was done via direct SSH edits on the live server, never committed anywhere). Reconciled by committing the live state as the new baseline and merging origin/master history with `-s ours` (preserves lineage without overwriting the live content), then pushed. `/tmp/jarvis-deploy` (a separate, stale clone used briefly as a staging area) is now superseded — don't use it; edit `/var/www/jarvis` directly.
**KB Intent Generator cron was silently broken since ~2026-07-05.** The rotation-engine refactor (moving from a hardcoded topic array to the `kb_generator_topics` table) had dropped the `$SYSTEM` prompt and the entire `safe_insert()` function — every 6-hour run crashed instantly on an uncaught `TypeError`, invisible everywhere because `config.php` sets `error_reporting(0)`. Restored both from a `.bak2` backup. Table grew from 386 → 900+ real intents once fixed.
**GitHub auto-deploy pipeline was completely non-functional — likely since it was first set up, not just recently:**
- The GitHub webhook itself pointed at the dead port `:1972` (already known-stale) → 502 on every delivery
- `WEBHOOK_SECRET` was never defined in `config.php``webhook.php` would have 500'd even if reached
- Branch check compared against `refs/heads/main`, but this repo has always used `master` → every real push would have been silently skipped
- `/usr/local/bin/jarvis-deploy.sh` (the queue consumer) didn't exist on this host at all
- **All fixed**: webhook URL updated to use port **1972** (confirmed working again after the 2026-07-07 WAN IP change — Cloudflare passes this non-standard port through cleanly, unlike port 80 which it force-redirects to HTTPS), secret added, branch check fixed, `jarvis-deploy.sh` rebuilt (git pull + per-file PHP syntax check + auto-revert on failure, `/var/log/jarvis/deploy.log`, cron `* * * * *`) and `jarvis-watchdog.sh` rebuilt (checks nginx/php8.3-fpm/mariadb/redis-server, restarts if down, `/var/log/jarvis/watchdog.log`, cron `*/5 * * * *`) — both were referenced in the Works tab and by cron history but the actual scripts had never existed on this VM. Verified end-to-end with a real push: webhook → queue → cron puller → `git pull` → live, inside ~1 minute.
**Admin panel (Works / Site Health / Email Intelligence tabs) — several real bugs, not just polish:**
- Facts Collector always showed "UNKNOWN" last-run (log-format regex expected a `[bracketed]` timestamp; this script's lines don't have one) — it was actually running fine every 3 min the whole time
- Calendar Sync always showed "UNKNOWN" *and* was never actually scheduled at all (no crontab entry existed, despite the UI listing it as an active "every 15 min" job) — added the missing cron entry, fixed a regex typo (`\d{2}{4}``\d{4}`)
- Only KB Intent Generator's "RUN" button had a live-streaming-log popup; Facts Collector/Stats Cache/Calendar Sync just fired a toast with no way to see the eventual result. Added a generic `worker_log`/`worker_history` backend pair and reused the same popup pattern for all of them
- Site Health: `parkerslingshotrentals.com` showed as truncated "parkerslingshots" (stale frontend label map referencing an old backend key name that no longer exists) — fixed, and widened the card layout (`.site-grid`/`.site-card`, 260px min vs the shared 160px stat-card) since domain names don't fit the generic dashboard-stat width. Also fixed the site-check freshness guard (300s vs a 180s cron interval meant sites were only re-checked every *other* run) — now 170s
- **Email Intelligence inbox always 404'd** — it proxied to `https://165.22.1.228/api/email`, a leftover from before JARVIS moved off the DO server; that endpoint hasn't existed since the migration. Now reads `email_triage` directly (same table `email_action_items` already used)
- **Compose/reply email silently produced blank drafts regardless of AI provider status** — the PHP dispatcher and the Python reactor used different field names for the same payload (`recipient`/`subject`/`auto_send` vs the reactor's `to_email`/`subject_hint`/`send`; `content` vs `body` for replies). Fixed both call sites
- **`llm_call()`'s provider fallback never actually fired** for any explicitly-requested provider (i.e. every real caller) — a failure on the first-choice provider raised straight up instead of falling through to the others. Fixed; also fixed `_ollama_call()` silently returning `""` instead of raising when Ollama reports an error (e.g. model not pulled), which had been masking failures as fake successes
- **Ollama had no text-completion model installed** (only `llava:7b`, the vision model) — pulled `llama3.1:8b` (~4.9GB) so the Claude→Groq→Ollama fallback chain is a real 3-tier chain, not 1.5. Bumped Ollama's call timeout 30s→90s (cold loads alone took 15s+ in testing)
- Added Groq 429 retry-with-backoff (parses Groq's own rate-limit-reset header) — this account's tier is only 12,000 tokens/min, and `gmail_triage` alone can exhaust it, so transient contention with compose/reply is common
- **Compose modal used to toast "dispatched" and never check back** — if the job failed 3090s later in the background there was no notification at all, it just silently vanished. Now polls the actual job status (`arc_job_get`) until done/failed and shows the real result
- Added a missing **SEND button** for queued drafts in the Outbox — compose had always only ever produced a `status='queued'` draft with no way to actually send it. Also fixed VIEW always showing "(no body)" (it read from the list endpoint, which omits body for performance, instead of the single-item endpoint)
- Still open: Claude API credits are depleted (billing, not a code issue) — restore at console.anthropic.com if Claude-quality drafting is wanted as the default again
### Architecture (end-to-end)
@@ -484,12 +610,49 @@ Voice (browser mic)
→ /api/chat.php (4-tier AI)
Tier 0.7: KB intents / planner (tasks, appointments)
Tier 1: Knowledge Base (MySQL)
Tier 1.5: Ollama (10.48.200.210:11434, llama3.2) — local LLM
Tier 1.5: Ollama (10.48.200.210:11434, llama3.1:8b) — local LLM
Vision: Ollama llava:7b (via Arc Reactor _vision_call cascade)
Tier 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
```
### Arc Reactor (AI Job Processor)
**Service:** `jarvis-arc` (systemd) — port 7474
**Runtime:** `/opt/jarvis-arc/` (Python venv, `reactor.py`)
**Log:** `/var/log/jarvis/arc.log`
**Admin button:** Workers → Daemons → `SETUP` (live popup) / `RESTART`
**Vision:** Claude → Ollama llava:7b → graceful fallback
**Vision config:** `/etc/systemd/system/jarvis-arc.service.d/vision.conf`
```bash
systemctl status jarvis-arc
systemctl restart jarvis-arc
journalctl -u jarvis-arc -f
```
To re-deploy Arc Reactor from source:
Use **Workers → Daemons → SETUP** in JARVIS admin (live log popup shows progress).
### Planner: Tasks / Directives / Missions
Admin UI sections backed by real schema + live API — verified working 2026-07-02 (created/read/deleted a test row in each, end-to-end).
**Tasks** — simple to-do list, stored directly in `jarvis_db`.
- Table: `tasks` (title, notes, category, priority [urgent/high/normal/low], status [pending/in_progress/done/cancelled], due_date, due_time)
- API: `task_list` (GET), `task_save` (POST, **form-encoded**), `task_done` (POST), `task_delete` (POST)
**Directives** — OKR-style goals with key results, stored directly in `jarvis_db`.
- Tables: `directives` (title, description, category, status, priority, target_date) + `directive_key_results` (directive_id, title, current_value, target_value, unit) + `directive_links` (directive_id, link_type, link_id — links a directive to a task/etc.)
- API: `directive_list`, `directive_get`, `directive_save` (POST, **JSON body** via `php://input`, id passed as `?id=` query param on update), `directive_delete`
**Missions** — automation workflows, NOT stored in `jarvis_db` — proxied through **Arc Reactor** (port 7474) which owns the mission state.
- Arc Reactor endpoints used by admin: `GET/POST /missions`, `GET /missions/{id}`, `GET /missions/{id}/runs`, `PUT/POST /missions/{id}`, `DELETE /missions/{id}`, `POST /missions/{id}/run`
- JARVIS-side mirror tables exist (`missions`, `mission_runs`, `mission_steps`) but the admin panel reads/writes live via Arc Reactor's HTTP API, not directly against these tables
- API: `mission_list`, `mission_get`, `mission_runs`, `mission_save` (POST, **JSON body**, id as `?id=` on update), `mission_delete`, `mission_run`, `mission_toggle`
- If Arc Reactor is down, these calls return `{"error":"Arc Reactor unreachable"}` — check `systemctl status jarvis-arc` first
As of 2026-07-02: all three tables are empty (0 rows) — features are fully functional, just unused so far.
### Deploy Pipeline
```
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
@@ -568,9 +731,11 @@ fs_cli -x "reloadacl" # reload ACL (safe)
## 9. NETWORKING & VPN
### FortiGate Firewall
- WAN IP: 97.154.109.245 (dynamic)
- WAN IP: 97.245.77.197 (dynamic — changed 2026-07-07; see Section 1 for the full VIP list, all 35 updated same day)
- DDNS: `orbisne.fortiddns.com` (FortiGate auto-updates on IP change)
- **Note:** a WAN IP change requires manually updating every VIP's `extip` (the FortiGate does not do this automatically even though DDNS updates) — the FortiGate CLI script used 2026-07-07 is a simple loop of `edit "<vip-name>"` / `set extip <new-ip>` / `next` for all 35 VIPs. Also update Cloudflare's DNS A-records for any `*.orbishosting.com` hostnames pointing at this WAN IP.
- Blocks: outbound port 53 (DNS) — MediaStack uses PVE1 dnsmasq (10.48.200.90) as resolver → 100.100.100.100
- **Upstream DNS (changed 2026-07-05):** Network → DNS set to "Specify" mode — Primary `1.1.1.1` (Cloudflare), Secondary `8.8.4.4` (Google). Previously defaulted to the router itself (`10.48.200.1`)/ISP-provided servers. Admin: `https://10.48.200.1:9443``admin / Joker1974!!!`. Note: `8.8.8.8` specifically showed as "Unreachable" during setup (transient — ISP's own DNS servers were also showing high latency at that moment); `1.1.1.1`/`8.8.4.4` tested healthy and are what's live now.
**Port Forwards:**
| External Port | Internal Destination | Purpose |
@@ -586,35 +751,75 @@ fs_cli -x "reloadacl" # reload ACL (safe)
### NordVPN — MediaStack Internet Traffic
- Interface: `nordlynx` on MediaStack
- Exit IP: 181.214.226.188 (US Dallas)
- Policy routing: table 205 (all traffic via nordlynx), managed by `nordvpn-routing.service`
- Exit: rotating US (Dallas) NordVPN servers, not a fixed IP
- Policy routing: table 205 (non-LAN traffic via nordlynx), managed partly by NordVPN itself and partly by a custom `nordvpn-routing.service` unit (`/etc/systemd/system/nordvpn-routing.service`) that adds the `ip rule` for fwmark `0xe1f1` → table 205
- Required for IPTorrents access (blocks non-VPN IPs)
**2026-07-06 incident (fixed):** `nordvpnd` had been crash-looping since ~2026-06-07 (`/var/lib/nordvpn/data/settings.dat` was corrupted/empty) — meaning NordVPN was **not actually protecting MediaStack's traffic for about a month**; downloads were exiting on the plain home IP. Fixed by clearing the corrupt file, restarting the daemon, and re-logging in.
While fixing this, found NordVPN's own **"LAN Discovery" setting was `disabled`**. With `Routing: enabled` and LAN Discovery off, connecting NordVPN's full-tunnel routing suppresses the main routing table's resolution for anything that would exit via `eth0`**including same-subnet LAN traffic** — so the entire VM became unreachable (SSH/ping) from the rest of the LAN the moment NordVPN connected. Fixed with `nordvpn set lan-discovery on`, which adds explicit priority rules for private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`) ahead of the tunnel catch-all. Verified LAN reachability holds with Kill Switch + Firewall both re-enabled after this fix.
**If MediaStack becomes unreachable after a NordVPN reconnect again:** check `nordvpn settings` for `LAN Discovery: enabled` first. If SSH is already dead, the network path itself is unusable — recover via the Proxmox host instead: `qm guest exec 103 -- /bin/bash -c 'export HOME=/root; nordvpn ...'` (guest-exec needs `HOME` set manually or the `nordvpn` CLI fails).
Current confirmed-good settings (2026-07-06): Firewall `enabled`, Kill Switch `enabled`, Auto-connect `enabled`, LAN Discovery `enabled`, Routing `enabled`.
---
## 10. BACKUP SYSTEMS
**Audited 2026-07-07 — file-coverage gap found and fixed in 2 of the 4 systems below.** The core question asked: if the source server were lost outright, does the backup actually contain what's needed to restore it, or just a database/config fragment? JARVIS and the DO server both failed that test and are now fixed; Proxmox and FusionPBX were checked and are genuinely fine as designed.
### JARVIS Backup
- **Script:** `/usr/local/bin/jarvis-backup.sh` (also at `/var/www/jarvis/deploy/`)
- **Output:** `/var/backups/jarvis/jarvis_backup_TIMESTAMP.tar.gz`
- **Log:** `/var/backups/jarvis/backup.log`
- **Retention:** 7 days (auto-purge)
- **Trigger:** JARVIS admin → Backups → RUN BACKUP NOW, or run script directly
- **DB:** `jarvis_db``jarvis_user / J4rv1s_Pr0t0c0l_2026!`
- **Fixed 2026-07-07:** this was MySQL-dump-only — a DB dump alone is useless without the actual application. Now also archives `/var/www/jarvis` (app code + `config.php`), `/opt/jarvis-arc` (reactor daemon), the nginx site config, the `jarvis-arc.service` systemd unit, and the root crontab. Also fixed a typo (`$SIYE``$SIZE`) that silently broke the backup-size line in the log. Latest test run: 20MB.
### DO Server Backup
- **Repo:** `myronblair/do-server-config`
- **Schedule:** Weekly, Sunday 4am
- **Launcher:** `/usr/local/bin/do-server-backup` on DO
- **Covers:** Scripts, systemd units, WireGuard, OLS vhosts, cron, MySQL credentials
- **Restore:** 8-phase wizard in `restore.sh`
- **DB backups:** `jarvis-backup.sh` runs daily (separate)
- **Fixed 2026-07-07:** previously covered scripts/systemd/WireGuard/OLS-vhosts/cron/SSH keys/`/opt/infra` — real server *configuration* — but captured **zero actual data**: no website files for any of the 7 hosted sites, and only a list of database *names* (`mysql/databases.txt`), never the data itself. If the droplet were lost, you'd get the server environment back but lose every site's real content and every business database. Now also archives `sites/<domain>/public_html/` for all 7 sites (rsync, `.git` excluded — each site's own repo already covers code history) and gzipped `mysqldump` output for every real business DB (`epic_epic_db`, `epic_parkersling`, `park_slingshot`, `toms_tjj_db`, `tomt_ttg_db`, `workt_track_db` — CyberPanel's own internal DB and MySQL system schemas are skipped). Verified live: all 6 DBs dumped with real content, all 7 sites' files present, repo still only 5.4MB after the first full run (318 files, ~48K lines).
- **Restore:** 8-phase wizard in `restore.sh` (predates the 2026-07-07 file/DB additions — the wizard itself hasn't been updated yet to restore `sites/`/`databases/`, only the original config pieces. Worth revisiting before relying on it for a full restore.)
- **Downloadable zip:** as of 2026-07-07, `backup.sh` also writes `/opt/do-server-backup.zip` (current working tree minus `.git`, regenerated every run) — see "Blair HQ Backup Downloads" below.
### Proxmox Config Backup
- **Repo:** `myronblair/proxmox-config`
- **Schedule:** Weekly, Sunday 3am (both PVE1 and PVE2)
- **Launcher:** `/usr/local/bin/proxmox-backup` on each node
- **Covers:** VM .conf files, network, cron, systemd, scripts
- **VM disks:** Covered by Proxmox Backup Server (PBS)
- **Covers:** VM .conf files, network, cron, systemd, scripts (host-level config only, by design)
- **VM disks:** **Verified 2026-07-07** — real nightly `vzdump` job `backup-aa6b1890-23c0` (all VMs, snapshot mode, keep-last=3, 21:00 daily, to `SynologyProx`) genuinely covers full VM disk data separately. Confirmed via `pvesh get /cluster/backup` — this is not just documented, it's an active, enabled job. No gap here; config-in-git + data-via-vzdump is a legitimate split, not an oversight.
### FusionPBX Backup
- **Repo:** `myronblair/fusionpbx-config`
- **Schedule:** Weekly, Sunday 5am
- **Launcher:** `/usr/local/bin/fusionpbx-backup`
- **Covers:** PostgreSQL dump (gzip, ~29MB) + FreeSWITCH configs
- **Covers:** PostgreSQL dump (gzip, ~29-60MB) + FreeSWITCH configs + call recordings
- **Verified 2026-07-07:** already does a real `pg_dump` of the actual `fusionpbx` database (dialplans, SIP gateways, IVR, ring groups, devices, voicemail, users — all real data, not just a name list) plus `pg_dumpall --globals-only`, plus config files and recordings. No gap here.
- **Restore:** 10-phase wizard in `restore.sh`
- **Known issue (found 2026-07-05, still open):** this repo has grown to ~166MB on GitHub / ~196MB on Gitea because the DB dump gets committed directly into git history on every backup run (3 copies in history as of this writing, each ~60MB) rather than being excluded/rotated. Options not yet decided: gitignore the dump going forward, or purge it from history with `git filter-repo` + force-push (destructive, needs explicit sign-off).
### Blair HQ Backup Downloads (`web.orbishosting.com`, added 2026-07-07)
A new **Backup Downloads** card on the Blair HQ dashboard (next to the existing Backup Status card) surfaces the actual JARVIS and DO Server backup files for direct download — source location, timestamp, and file size are shown, pulled from `/downloads/backup-files-status.json`.
- **Pull script:** `/usr/local/bin/pull-backups.sh` on VM110 (NovaCPX), cron `0 5 * * *` (daily, safely after both source backups) — SCPs the latest `jarvis_backup_*.tar.gz` from VM211 (key-based SSH; a new ed25519 keypair was generated on VM110 and added to VM211's `authorized_keys` for this specifically) and `/opt/do-server-backup.zip` from the DO server (password auth via `sshpass`), then writes the status JSON. Log: `/home/webacct/logs/pull-backups.log`.
- **Security fix that made this safe to build**: `/home/webacct/public_html/downloads/` had **zero access protection** — anything dropped there (including the infra reference doc, previously) was fetchable by anyone on the open internet with no login, since `web.orbishosting.com` is fully public (confirmed reachable from outside, HTTP 200). Added HTTP Basic Auth (`myron` / `Joker1974!!!`) scoped to the `/downloads/` location in `/etc/nginx/sites-enabled/novacpx-webacct.conf`, with `backup-status.json` and `backup-files-status.json` explicitly exempted (exact-match `location` blocks ahead of the auth'd prefix block) since the dashboard's own client-side JS fetches those with no credentials to send.
- **Also fixed while auditing this:** a daily VM110 cron (`0 4 * * * curl ... -o .../INFRASTRUCTURE-REFERENCE.md`) had been silently overwriting the infra doc with an empty file every night since the 2026-07-06 security review relocated the real copy off any public path — the cron's source URL no longer existed. Restored a LAN-only static copy at `/var/www/jarvis/public_html/admin/downloads/INFRASTRUCTURE-REFERENCE.md` on VM211 (safe: VM211 isn't internet-reachable directly, only from the internal LAN) so this automation has something real to pull again. The authenticated `docs_download` action in the JARVIS admin UI is unaffected either way.
### MSP360 Backup Status (Dashboard Integration)
- **Client software:** MSP360 (CloudBerry) Backup CLI installed on all 6 hosts — PVE1, JARVIS (211), NovaCPX (110), Jellyfin (33), MediaStack (35), Homebridge (18)
- **Storage target:** `NAS-MSPBackups` destination → Synology NAS CIFS share, mounted at `/mnt/nas-backups/MSPBackups`
- **Mount reliability:** `/usr/local/bin/msp360-mount-ensure.sh` (cron `*/15 * * * *` on hosts using the NAS mount) — bind-mounts the MSPBackups subdir onto itself since MSP360's pre-flight `mountpoint` check fails on a subdirectory of a CIFS mount otherwise
- **Collector:** `/usr/local/bin/backup-status-collect.sh` on PVE1 (runs via key-trusted root SSH — PVE1 is the only host with passwordless SSH to all 6 targets; other hosts use password auth via `sshpass`)
- Queries each host's plan via `cbb plan -l` (legacy v1 CLI — outputs `State:` / `Last result:` fields directly, unlike `cbbV2`/`cbbCommandLineV2` which needs `plan list -b` and different parsing)
- Writes `/tmp/backup-status.json`, then `scp`s it to `root@10.48.200.110:/home/webacct/public_html/downloads/backup-status.json`
- **Schedule:** daily `0 6 * * *` on PVE1 (`>> /var/log/backup-status-collect.log`)
- **Dashboard card:** `web.orbishosting.com` "BACKUP STATUS" card (`index.html`) fetches `/downloads/backup-status.json` client-side (`loadBackupStatus()`), color-codes dots by `result` (green=Success, yellow=Warning, red=Fail, cyan=Running, gray=unknown)
- **JSON schema:** `{"updated": "<ISO8601 UTC>", "hosts": [{"name","ip","state","result"}, ...]}`
- **Homebridge (2026-07-04): dropped MSP360 entirely.** After extensive troubleshooting (RAM starvation, a bug where its account scanned every other host's shared backup data, missing bind-mount depths, CIFS tuning, a full plan recreation) Homebridge's MSP360 agent kept failing with a false "storage drive not mounted" error at a consistent ~60-75s mark, root cause never conclusively identified (survived every environmental fix, looked like an app-level bug tied to any custom/non-default account path). Since Homebridge (VM 118) was already being backed up successfully every night by the cluster-wide Proxmox vzdump job (`backup-aa6b1890-23c0`, all VMs, 21:00 daily, keep-last=3, to `SynologyProx` storage), MSP360 was stopped/disabled on Homebridge (`systemctl disable msp360-backup.service msp360-backupWA.service`) and removed from the dashboard collector's per-host MSP360 check. The collector now reads Homebridge's status directly from `/mnt/pve/SynologyProx/dump/vzdump-qemu-118-*.vma.zst` on PVE1 instead of querying an in-guest agent.
- **Known state (2026-07-04):** 4/5 remaining MSP360 hosts report `Warning`, NovaCPX reports `Fail` — plan-level result, not investigated further; worth checking each host's MSP360 GUI/log for root cause if backups need to be trusted for restore. Homebridge reports `Success` via Proxmox.
---
@@ -646,13 +851,14 @@ sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.33
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.110
# Ollama / Homebridge / NetworkBackup (myron user, then sudo)
sshpass -p 'Joker1974!' ssh myron@10.48.200.95 # Ollama
sshpass -p 'Joker1974!' ssh myron@10.48.200.210 # Ollama (NOT .95 - that's a naming leftover, nothing listens there)
sshpass -p 'Joker1974!' ssh myron@10.48.200.18 # Homebridge
sshpass -p 'Joker1974!' ssh myron@10.48.200.99 # NetworkBackup
# Run command inside VM via Proxmox (requires QEMU agent installed)
# Run command inside VM via Proxmox (requires QEMU agent installed; use VMID not IP)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'qm guest exec 210 -- bash -c "CMD"'
'qm guest exec 106 -- bash -c "export HOME=/root; CMD"' # Ollama = VMID 106
# Other VMIDs: Jellyfin=112, MediaStack=103, JARVIS=211, NovaCPX=120, HomeAssistant=101, Homebridge=118
```
**Password fallback order:** `Joker1974!@#``Joker1974!!!``Joker1974!`
@@ -680,12 +886,13 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
| phpMyAdmin (DO) | https://165.22.1.228/phpmyadmin | myron | `Joker1974!!!` |
| Proxmox PVE1 | https://orbisne.fortiddns.com:8006 | root | `Joker1974!!!` |
| Proxmox PVE2 | https://10.48.200.91:8006 | root | `Joker1974!!!` |
| JARVIS | http://jarvis.orbishosting.com:1972 | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com:1972/admin | myron | `Joker1974!!!` |
| JARVIS | http://jarvis.orbishosting.com | myron | `Joker1974!!!` |
| JARVIS Admin | http://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
| ChuckCo Time Keeper Admin | https://worktracking.orbishosting.com/admin/login.php | — | `Joker1974!!!` |
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
| Jellyfin | http://10.48.200.33:8096 | — | token: `7c0ccf78b91d4b5bafa607f585f24f2d` |
| Jellyfin | http://10.48.200.33:8096 | — | token: `635142c218f2457e813a2de8dec75b05` |
| qBittorrent | http://10.48.200.35:8080 | admin | `Joker1974!!!` |
| Sonarr | http://10.48.200.35:8989 | admin | `Joker1974!!!` |
| Radarr | http://10.48.200.35:7878 | admin | `Joker1974!!!` |
@@ -703,20 +910,21 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
| Epic Travel | `epic_travel_db` | (see config.php) | (see config.php) |
| Epic/Parker Slingshot | `epic_parkersling` | `epic_parkersling` | `Joker1974!!!` |
| NovaCPX | SQLite: `/var/lib/novacpx/panel.db` | — | — |
| ChuckCo Time Keeper | `workt_track_db` | `workt_track_user` | `ZWCNMRP2N5NVPsghmve5aRS9` |
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
### API Keys
| Service | Key |
|---------|-----|
| GitHub PAT | `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` (exp ~2026-08-20) |
| GitHub PAT | `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` (rotated 2026-07-05, scopes `repo`+`workflow`) |
| JARVIS Agent Registration | `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518` |
| Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` |
| HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` |
| Sonarr API | `b43e04350a594846b4ee95261c29e9e0` |
| Radarr API | `53c4268360444feeae5f98c0cc24e0e3` |
| Prowlarr API | `9d0ce6c5660743b5bf1c7951efc62252` |
| Jellyfin Admin Token | `7c0ccf78b91d4b5bafa607f585f24f2d` |
| Jellyfin Admin Token | `635142c218f2457e813a2de8dec75b05` |
| Square (Parker) Production | `EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v` |
| Square App ID (Parker) | `sq0idp-YSM7BU9IVyOWSzpeP-0nzQ` |
| Webhook HMAC Secret | `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1` |
@@ -735,4 +943,102 @@ sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
---
## 13. GIT & REPOSITORY MANAGEMENT
**GitHub** (`myronblair`, 26 private repos as of 2026-07-05) is the permanent/source-of-truth storage. **Gitea** (`gitea.orbishosting.com`, hosted on the Synology NAS, login `myron / Joker1974!!!`) mirrors it one-way — GitHub → Gitea only, via pull-mirrors triggered by `POST /api/v1/repos/<owner>/<repo>/mirror-sync`. Never push directly to a Gitea remote.
**5 repos are Gitea-only by design, never pushed to GitHub** (deliberately kept off a third-party cloud service since they hold real credentials): `fortigate-config`, `infra-private`, `jarvis-secrets`, `msp360-config`, `proxmox-secrets`.
**Local clones** live on the admin Windows machine at `C:\Users\myron\repos\` (currently `chucko`, `web-dashboard` — more cloned there as needed). Periodic maintenance: `git gc --aggressive --prune=now` to keep loose objects packed.
**Per-site deploy pattern (the 7 DO-hosted sites + jarvis + web-dashboard):** `.git` metadata is relocated outside the public webroot (e.g. `/home/<site>/git-data` with a `gitdir:` pointer file left in `public_html/.git`) so `.git` itself is never web-accessible, while `git` commands run normally from inside `public_html`. Most sites deploy via a GitHub webhook → queue file → cron puller (~1 min); `chucko` (ChuckCo Time Keeper) currently has no auto-deploy hook — pushes are manual (SSH in, `git pull`/`push` directly).
**Known repo-hygiene issue:** `fusionpbx-config` bloated to ~166-196MB from repeatedly committing a large DB dump straight into history — see Section 10.
---
## 14. NETWORK EQUIPMENT & CLIENT DEVICE INVENTORY
**Compiled 2026-07-06** from a live ARP scan off PVE1 (~90 hosts), MAC-vendor lookups, and direct confirmation from Myron. Built to support a future VLAN segmentation project — see `VLAN-Segmentation-Plan.docx` in the home folder for the full plan; this section is the durable factual record to carry forward (e.g. into JARVIS) independent of that plan's status.
### 14.1 Core Network Equipment
| Device | Model | Role |
|--------|-------|------|
| Firewall | **FortiGate 60F** | Primary/active firewall — confirmed the top unit in the rack. A second Fortinet unit is stacked below it; its role is not yet identified — not confirmed as an HA pair. |
| Primary switch | **Cisco Catalyst 3560-E Series PoE-48** | 48-port, full PoE, enterprise-managed — full 802.1Q VLAN/trunk support |
| Secondary switch | **FortiSwitch 108F-FPOE** | 8-port PoE, FortiLink-managed |
| KVM switch | **TRENDnet TK-802R** | Physical console access to rack servers — not networked |
| WiFi extender 1 | **TP-Link RE305** — 10.48.200.16 | WiFi clients only, no wired devices |
| WiFi extender 2 | **TP-Link RE305** — 10.48.200.89 | WiFi clients only, no wired devices |
| WiFi extender 3 | **TP-Link RE305** — 10.48.200.93 | Wired network printers plugged into its Ethernet port; no WiFi clients on this unit |
| WiFi extender 4 | Brand unconfirmed (Shenzhen Xunman-branded/OEM) — MAC `fc:22:1c:30:60:14` seen at 10.48.200.100/.64 | Wired Pioneer VSX-822 AV receiver plugged into its Ethernet port; no wireless clients of its own — same bridge-mode pattern as extender 3 above |
| Wireless bridge | **Good Story Networks WB610H** — 10.48.200.80 | Links the main house network to the storage shed (a detached building). OEM manufacturer is Shenzhen LiWiFi Technology Co., Ltd (rebranded by Good Story Networks). Plan: once fully deployed, this bridge replaces the need for the RE305 units at .16 and .89 — see the VLAN plan doc for details. VLAN/802.1Q trunk capability not yet confirmed — check before relying on it for segmented WiFi. |
**Note on consumer extenders and VLANs:** budget range extenders like the RE305 (and likely extender 4 above) generally cannot map multiple SSIDs to separate VLANs over a trunk — they repeat one network. This matters if/when wireless VLAN segmentation is implemented; see the VLAN plan doc's "Wireless VLAN Feasibility" section.
### 14.2 Client & Peripheral Device Inventory (by category)
**Printers:**
| IP | Device |
|----|--------|
| 10.48.200.76 | Epson ET-3750 |
| 10.48.200.204 | HP LaserJet 500 Color MFP M570dn (wired to the RE305 at .93) |
| 10.48.200.205 | HP LaserJet M1536dnf MFP (wired to the RE305 at .93) |
| 10.48.200.201, .202 | Unidentified — likely more printers/peripherals on the same RE305 port, given the pattern above (not yet confirmed) |
**Storage:**
| IP | Device |
|----|--------|
| 10.48.200.249 | Synology NAS |
| 10.48.200.41 | WD My Cloud — a second NAS alongside the Synology |
**AV / Entertainment:**
| IP | Device |
|----|--------|
| 10.48.200.42, .72 | Vizio smart TVs |
| 10.48.200.100 | Pioneer VSX-822 AV Receiver — confirmed 2026-07-06, wired into a 4th WiFi range extender/AP (Shenzhen Xunman-branded/OEM, MAC `fc:22:1c:30:60:14`, no wireless clients of its own). This explains the earlier MAC discrepancy cleanly: the extender reports its own MAC in ARP for the wired Pioneer behind it, same bridge-mode pattern seen with the RE305 at `.93` and its wired printers — the Pioneer's own labeled MAC (`74:5E:1C:0E:7C:0B`, genuine Pioneer Corporation OUI) simply never appears on the wire. 10.48.200.64 shares this same extender's MAC — likely the same device (Pioneer or the extender itself) at a prior IP, not a separate device. |
**Smart home / IoT:**
| IP | Device |
|----|--------|
| 10.48.200.38 | Samsung SmartThings hub (MAC vendor: Physical Graph Corporation, the original SmartThings company) |
| 10.48.200.250, .251 | Goalake Smart Switch 1 and 2 |
| 10.48.200.5, .7, .8, .9, .36, .61, .74 | Generic ESP32/ESP8266-based smart plugs/sensors (Espressif chipset) |
| 10.48.200.14, .60 | Tuya Smart plugs/switches |
| 10.48.200.34 | Bouffalo Lab-chipset IoT device |
| 10.48.200.116 | FN-LINK-chipset IoT device |
| 10.48.200.6, .10, .15, .23, .24, .27, .28, .30, .31, .32, .37, .62, .82, .86, .87, .105 | TP-Link Tapo smart devices (16 total) |
| 10.48.200.17, .39, .40, .46, .53, .68, .71, .106, .115, .118 | Amazon devices (Echo/Fire TV/Kindle) |
**Security cameras:**
| IP | Device |
|----|--------|
| 10.48.200.57, .78, .94, .95, .101, .103, .104 | Reolink cameras + NVR (7 addresses total — one of these is the NVR itself, not confirmed which). Cameras are PoE-connected directly to the Catalyst 3560-E, not cabled to the NVR; the NVR pulls streams over the network like any other client. Feeds are viewed both locally on the LAN and remotely via the Reolink app. |
| 10.48.200.21, .22 | Ring doorbell/camera — also viewed both locally and via the Ring app remotely |
**VoIP (Yealink) — see Section 8 for extension details:**
`.2, .3, .43, .65, .83, .85`
**Personal computers / hobby devices:**
| IP | Device |
|----|--------|
| 10.48.200.54 | Apple device (iPhone/iPad/Mac) |
| 10.48.200.66 | Dell PC |
| 10.48.200.52 | Intel-NIC PC |
| 10.48.200.45 | Microsoft device (Surface or Xbox — not yet confirmed which) |
| 10.48.200.50 | Raspberry Pi 5 — hobby/tinkering only, no production workload |
**Still unidentified as of 2026-07-06** (MAC vendor lookup only, no direct confirmation yet):
| IP | Vendor signature | Notes |
|----|----|----|
| 10.48.200.13 | Murata Manufacturing | Embedded WiFi module — device unknown |
| 10.48.200.59 | Guangzhou Shiyuan Electronic | Often AV/display equipment — device unknown |
| 10.48.200.119 | Macherey-Nagel GmbH & Co. KG | A lab-equipment brand (chromatography/filtration) — unusual on a home network, device unknown |
| 10.48.200.77 | Liteon Technology | Could be a PC PSU with network mgmt, or a peripheral — device unknown |
| 10.48.200.201, .202 | (shares MAC with the RE305 at .93 and the confirmed printers at .204/.205) | Likely more printers/peripherals, not confirmed |
**Minor known artifact, low priority:** MAC `7a:e1:7e:44:08:29` is shared across 10.48.200.93 (RE305), .204/.205 (confirmed printers, benign RE305 bridge-mode behavior), and .251 (Goalake Smart Switch 2 — does NOT fit the "wired to the RE305" explanation, so this one specific overlap looks like a genuine duplicate/cloned MAC, common in ultra-budget IoT hardware). Worth a quick check of the Goalake switch's real MAC via its own admin UI at some point; not urgent.
---
*This document contains sensitive credentials. Store securely and do not share.*
+5561 -4760
View File
File diff suppressed because it is too large Load Diff
+17 -38
View File
@@ -6,20 +6,26 @@
.DESCRIPTION
Installs JARVIS Agent as a Windows Service that auto-starts at boot.
Requires: PowerShell 5.1+, internet access, and Administrator rights.
No Python installation needed this installs the standalone .exe build.
.EXAMPLE
# Interactive install (prompts for registration key):
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex
# Silent install with key:
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com:1972/agent/install-windows.ps1 | iex
#>
$ErrorActionPreference = 'Stop'
$JARVIS_URL = 'https://jarvis.orbishosting.com'
# Fixed 2026-07-07: jarvis.orbishosting.com on the default port (80/443) is not
# reachable from outside the LAN at all (no FortiGate VIP forwards it) — every
# external install using the old default URL would have failed outright. Port
# 1972 is the confirmed-working external path (same fix applied to the GitHub
# webhook the same day).
$JARVIS_URL = 'http://jarvis.orbishosting.com:1972'
$INSTALL_DIR = 'C:\ProgramData\jarvis-agent'
$SERVICE_NAME = 'JARVISAgent'
$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py"
$AGENT_EXE = "$INSTALL_DIR\jarvis-agent-windows.exe"
$CONFIG_FILE = "$INSTALL_DIR\config.json"
function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan }
@@ -39,49 +45,23 @@ if ($existing) {
Start-Sleep 2
}
try {
& python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null
if (Test-Path $AGENT_EXE) { & $AGENT_EXE remove 2>$null }
} catch {}
Write-OK "Existing service removed."
}
# ── Check / install Python ────────────────────────────────────────────────────
Write-Step "Checking Python..."
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) {
Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run."
}
winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User")
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" }
}
$pyVersion = & python --version 2>&1
Write-OK $pyVersion
# ── Install pywin32 ───────────────────────────────────────────────────────────
Write-Step "Checking pywin32..."
$checkWin32 = & python -c "import win32service; print('ok')" 2>&1
if ($checkWin32 -ne 'ok') {
Write-Host " Installing pywin32..." -ForegroundColor Yellow
& python -m pip install --quiet pywin32
& python -m pywin32_postinstall -install 2>$null
Write-OK "pywin32 installed."
} else {
Write-OK "pywin32 already installed."
}
# ── Create install dir ────────────────────────────────────────────────────────
Write-Step "Creating install directory..."
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
Write-OK $INSTALL_DIR
# ── Download agent script ─────────────────────────────────────────────────────
# ── Download agent exe ─────────────────────────────────────────────────────────
# No Python/pywin32 dependency anymore — this is a self-contained PyInstaller
# build with everything it needs bundled in.
Write-Step "Downloading JARVIS agent..."
try {
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing
Write-OK "Agent downloaded to $AGENT_SCRIPT"
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.exe" -OutFile $AGENT_EXE -UseBasicParsing
Write-OK "Agent downloaded to $AGENT_EXE"
} catch {
Write-Fail "Failed to download agent: $_"
}
@@ -121,8 +101,7 @@ Write-OK "Config written to $CONFIG_FILE"
# ── Install Windows Service ───────────────────────────────────────────────────
Write-Step "Installing Windows service..."
$pyPath = (Get-Command python).Source
& $pyPath "$AGENT_SCRIPT" --startup auto install
& $AGENT_EXE --startup auto install
if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." }
Write-OK "Service '$SERVICE_NAME' installed."
+11 -4
View File
@@ -1,15 +1,22 @@
#!/bin/bash
# JARVIS Agent Installer — one-liner for any Linux host:
# curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s <hostname> <agent_type>
# curl -sk http://jarvis.orbishosting.com:1972/agent/install.sh | bash -s <hostname> <agent_type>
#
# agent_type: linux | proxmox | homeassistant
# Example: curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s myserver linux
# Example: curl -sk http://jarvis.orbishosting.com:1972/agent/install.sh | bash -s myserver linux
#
# On the LAN, set JARVIS_URL to the direct internal address instead (faster,
# doesn't hairpin through Cloudflare): JARVIS_URL=http://10.48.200.211 curl ... | bash -s ...
set -e
HOSTNAME_ARG="${1:-$(hostname -s)}"
AGENT_TYPE="${2:-linux}"
JARVIS_URL="${JARVIS_URL:-https://jarvis.orbishosting.com}"
# Fixed 2026-07-07: jarvis.orbishosting.com on the default port isn't reachable
# from outside the LAN at all (no FortiGate VIP forwards it) — :1972 is the
# confirmed-working external path (same fix as the GitHub webhook and the
# Windows agent installer).
JARVIS_URL="${JARVIS_URL:-http://jarvis.orbishosting.com:1972}"
JARVIS_HOST=""
INSTALL_DIR="/opt/jarvis-agent"
CONFIG_DIR="/etc/jarvis-agent"
@@ -57,7 +64,7 @@ else
"poll_interval": 30,
"heartbeat_every": 10,
"update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"]
"watch_services": []
}
JSONEOF
chmod 600 "$CONFIG_DIR/config.json"
Binary file not shown.
@@ -0,0 +1 @@
ad9b59c09e5862c5abc35f73999aa2666f8401817b053c385505fa420ca473e7
+55 -28
View File
@@ -35,12 +35,11 @@ INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent")
CONFIG_PATH = INSTALL_DIR / "config.json"
STATE_PATH = INSTALL_DIR / "state.json"
LOG_PATH = INSTALL_DIR / "jarvis-agent.log"
AGENT_VERSION = "3.1"
AGENT_VERSION = "3.2"
# Set by the service wrapper so self_update knows to stop instead of exec
_is_service = False
_stop_event = threading.Event()
_update_restart = False # True when stopping for self-update; triggers SCM restart
_is_service = False
_stop_event = threading.Event()
# ── Logging ────────────────────────────────────────────────────────────────────
@@ -93,7 +92,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
@@ -110,7 +109,7 @@ def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
def api_get(url: str, headers: dict = {}, timeout: int = 10,
ssl_verify: bool = True) -> dict:
req = urllib.request.Request(url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
@@ -376,18 +375,28 @@ def _sysinfo_snapshot() -> dict:
# ── Self-update ────────────────────────────────────────────────────────────────
def self_update(cfg: dict) -> bool:
# Added: supports both script-mode (plain .py, run via a system Python) and
# frozen-mode (standalone PyInstaller .exe — sys.frozen is set, __file__ isn't
# meaningful/writable the way it is for a real .py file on disk). A running
# .exe can't be overwritten in place on Windows, but CAN be renamed while
# running, so frozen mode uses a download-new/rename-old/rename-new swap
# instead of the direct overwrite the script-mode path uses.
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else ""
is_frozen = bool(getattr(sys, "frozen", False))
if is_frozen:
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.exe" if jarvis_url else ""
else:
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else ""
update_url = cfg.get("update_url", default_update_url)
if not update_url:
return False
script_path = os.path.abspath(__file__)
target_path = os.path.abspath(sys.executable) if is_frozen else os.path.abspath(__file__)
ssl_verify = bool(cfg.get("ssl_verify", True))
try:
# Download expected hash
hash_url = update_url + ".sha256"
req_hash = urllib.request.Request(hash_url)
req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req_hash.add_header("Host", _host_header)
expected_hash = None
@@ -398,13 +407,13 @@ def self_update(cfg: dict) -> bool:
except Exception:
pass
# Download new script
# Download new script/exe
req = urllib.request.Request(update_url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.2")
if _host_header:
req.add_header("Host", _host_header)
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
new_content = resp.read()
# Verify hash
@@ -414,21 +423,42 @@ def self_update(cfg: dict) -> bool:
log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting")
return False
with open(script_path, "rb") as f:
with open(target_path, "rb") as f:
current = f.read()
if new_content != current:
log(f"Update verified — replacing {script_path} and restarting...")
with open(script_path, "wb") as f:
if new_content == current:
return False
log(f"Update verified — replacing {target_path} and restarting...")
if is_frozen:
# Can't overwrite a running exe, but can rename it and drop the new
# one in its place; the old copy is cleaned up on the next update.
old_path = target_path + ".old"
new_path = target_path + ".new"
with open(new_path, "wb") as f:
f.write(new_content)
if _is_service:
global _update_restart
_update_restart = True
log("Running as service — stopping for SCM-managed restart after update.")
_stop_event.set()
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
return True
return False
try:
if os.path.exists(old_path):
os.remove(old_path)
except Exception:
pass
os.rename(target_path, old_path)
os.rename(new_path, target_path)
else:
with open(target_path, "wb") as f:
f.write(new_content)
if _is_service:
# Signal the main loop to exit; SCM failure-recovery will restart us
log("Running as service — stopping for SCM-managed restart after update.")
_stop_event.set()
elif is_frozen:
# sys.argv[0] is already the exe's own path for a frozen app — don't
# prepend sys.executable again or the new process misreads its own
# path as a command-line argument.
os.execv(sys.executable, sys.argv)
else:
os.execv(sys.executable, [sys.executable] + sys.argv)
return True
except Exception as e:
log(f"Self-update check failed: {e}")
return False
@@ -592,9 +622,6 @@ if _HAS_WIN32:
(self._svc_name_, ""),
)
main()
if _update_restart:
# Non-zero exit triggers SCM failure recovery → automatic restart
sys.exit(1)
if __name__ == "__main__":
@@ -1 +1 @@
224a634375b5d49ccc0a012e0e122ade5f8a1302615450dffbf9a03eac6b7a19
fff217657488830084780115665d0c772af1f7fe31f2084d61a7560424a5a91a
+1 -1
View File
@@ -281,7 +281,7 @@ def get_uptime() -> dict:
return {}
def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["ollama", "homeassistant", "mysql", "nginx", "apache2"])
watch = cfg.get("watch_services", [])
statuses = []
for svc in watch:
try:
+128 -123
View File
@@ -1,123 +1,128 @@
<?php
/**
* JARVIS API Router fault-isolated per endpoint
* A ParseError or fatal in any endpoint file returns JSON 500 for that
* endpoint only; all other endpoints continue to work normally.
*/
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php';
// Skip session for machine-agent calls and netscan/ping — each heartbeat would
// otherwise create an empty session file, producing millions of files that slow
// session GC for all requests. Browser-facing agent sub-actions (list/status/myip)
// still need a session to verify auth, so we only skip for machine-agent actions.
$_earlyParts = explode('/', trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts);
$_e0 = $_earlyParts[0] ?? '';
$_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) {
$_e0 === 'ping' => true,
$_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
default => false,
};
if (!$_skipSession) {
session_start();
}
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$method = $_SERVER['REQUEST_METHOD'];
$path = trim(parse_url($uri, PHP_URL_PATH), '/');
$parts = explode('/', $path);
if (($parts[0] ?? '') === 'api') array_shift($parts);
$endpoint = $parts[0] ?? '';
$action = $parts[1] ?? '';
// ── Auth check (skip for auth / agent / netscan) ──────────────────────
if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) {
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
$isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? '');
if (!$isValid) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true);
if (!$isLocal && $endpoint !== 'ping') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'code' => 401]);
exit;
}
}
}
if ($endpoint !== 'auth') session_write_close();
$body = file_get_contents('php://input');
$data = json_decode($body, true) ?? [];
// ── Fast ping (no file dispatch needed) ──────────────────────────────
if ($endpoint === 'ping') {
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
exit;
}
// ── Endpoint → file map ───────────────────────────────────────────────
$endpoints = [
'auth' => 'auth.php',
'chat' => 'chat.php',
'system' => 'system.php',
'netscan' => 'netscan.php',
'network' => 'network.php',
'proxmox' => 'proxmox.php',
'ha' => 'ha.php',
'tts' => 'tts.php',
'email' => 'email.php',
'do' => 'do_server.php',
'alerts' => 'alerts.php',
'facts' => 'facts_collector.php',
'weather' => 'weather.php',
'news' => 'news.php',
'sites' => 'sites.php',
'agent' => 'agent.php',
'planner' => 'planner.php',
'jellyfin' => 'jellyfin.php',
'history' => 'history.php',
'metrics' => 'metrics.php',
'suggestions' => 'suggestions.php',
'arc' => 'arc.php',
'directives' => 'directives.php',
'memory' => 'memory.php',
'calendar' => 'calendar_sync.php',
];
if (!isset($endpoints[$endpoint])) {
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
exit;
}
$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint];
// ── Fault-isolated dispatch ───────────────────────────────────────────
// ob_start() buffers any partial output so a mid-execution fatal doesn't
// send a broken response. catch(Throwable) catches ParseError, TypeError,
// and all other Errors + Exceptions in PHP 7+.
ob_start();
try {
require $file;
ob_end_flush();
} catch (\Throwable $e) {
ob_end_clean();
http_response_code(500);
echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]);
error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d',
$endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()
));
}
<?php
/**
* JARVIS API Router fault-isolated per endpoint
* A ParseError or fatal in any endpoint file returns JSON 500 for that
* endpoint only; all other endpoints continue to work normally.
*/
require_once __DIR__ . '/../api/config.php';
require_once __DIR__ . '/../api/lib/db.php';
require_once __DIR__ . '/../api/lib/kb_engine.php';
// Skip session for machine-agent calls and netscan/ping — each heartbeat would
// otherwise create an empty session file, producing millions of files that slow
// session GC for all requests. Browser-facing agent sub-actions (list/status/myip)
// still need a session to verify auth, so we only skip for machine-agent actions.
$_earlyParts = explode('/', trim(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'));
if (($_earlyParts[0] ?? '') === 'api') array_shift($_earlyParts);
$_e0 = $_earlyParts[0] ?? '';
$_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) {
$_e0 === 'ping' => true,
$_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
default => false,
};
if (!$_skipSession) {
session_start();
}
header('Content-Type: application/json');
$_allowedOrigins = ['https://jarvis.orbishosting.com', 'http://jarvis.orbishosting.com'];
$_origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($_origin, $_allowedOrigins, true)) {
header('Access-Control-Allow-Origin: ' . $_origin);
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, X-Session-Token');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$method = $_SERVER['REQUEST_METHOD'];
$path = trim(parse_url($uri, PHP_URL_PATH), '/');
$parts = explode('/', $path);
if (($parts[0] ?? '') === 'api') array_shift($parts);
$endpoint = $parts[0] ?? '';
$action = $parts[1] ?? '';
// ── Auth check (skip for auth / agent / netscan) ──────────────────────
if (!\in_array($endpoint, ['auth', 'agent', 'netscan'], true)) {
$token = $_SESSION['jarvis_token'] ?? ($_SERVER['HTTP_X_SESSION_TOKEN'] ?? '');
$isValid = !empty($token) && $token === ($_SESSION['jarvis_token'] ?? '');
if (!$isValid) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$isLocal = \in_array($ip, ['127.0.0.1', '::1', JARVIS_IP], true);
if (!$isLocal && $endpoint !== 'ping') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'code' => 401]);
exit;
}
}
}
if ($endpoint !== 'auth') session_write_close();
$body = file_get_contents('php://input');
$data = json_decode($body, true) ?? [];
// ── Fast ping (no file dispatch needed) ──────────────────────────────
if ($endpoint === 'ping') {
echo json_encode(['status' => 'online', 'time' => date('c'), 'codename' => JARVIS_CODENAME]);
exit;
}
// ── Endpoint → file map ───────────────────────────────────────────────
$endpoints = [
'auth' => 'auth.php',
'chat' => 'chat.php',
'system' => 'system.php',
'netscan' => 'netscan.php',
'network' => 'network.php',
'proxmox' => 'proxmox.php',
'ha' => 'ha.php',
'tts' => 'tts.php',
'email' => 'email.php',
'do' => 'do_server.php',
'alerts' => 'alerts.php',
'facts' => 'facts_collector.php',
'weather' => 'weather.php',
'news' => 'news.php',
'sites' => 'sites.php',
'agent' => 'agent.php',
'planner' => 'planner.php',
'jellyfin' => 'jellyfin.php',
'history' => 'history.php',
'metrics' => 'metrics.php',
'suggestions' => 'suggestions.php',
'arc' => 'arc.php',
'directives' => 'directives.php',
'memory' => 'memory.php',
'calendar' => 'calendar_sync.php',
];
if (!isset($endpoints[$endpoint])) {
http_response_code(404);
echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]);
exit;
}
$file = __DIR__ . '/../api/endpoints/' . $endpoints[$endpoint];
// ── Fault-isolated dispatch ───────────────────────────────────────────
// ob_start() buffers any partial output so a mid-execution fatal doesn't
// send a broken response. catch(Throwable) catches ParseError, TypeError,
// and all other Errors + Exceptions in PHP 7+.
ob_start();
try {
require $file;
ob_end_flush();
} catch (\Throwable $e) {
ob_end_clean();
http_response_code(500);
echo json_encode(['error' => 'Endpoint unavailable', 'endpoint' => $endpoint, 'code' => 500]);
error_log(sprintf('JARVIS API [%s] %s: %s in %s:%d',
$endpoint, get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()
));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -1,16 +1,23 @@
#!/bin/bash
# JARVIS Agent Installer — one-liner for any Linux host:
# curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s <hostname> <agent_type>
# curl -sk http://jarvis.orbishosting.com:1972/agent/install.sh | bash -s <hostname> <agent_type>
#
# agent_type: linux | proxmox | homeassistant
# Example: curl -sk https://jarvis.orbishosting.com/install-agent.sh | bash -s myserver linux
# Example: curl -sk http://jarvis.orbishosting.com:1972/agent/install.sh | bash -s myserver linux
#
# On the LAN, set JARVIS_URL to the direct internal address instead (faster,
# doesn't hairpin through Cloudflare): JARVIS_URL=http://10.48.200.211 curl ... | bash -s ...
set -e
HOSTNAME_ARG="${1:-$(hostname -s)}"
AGENT_TYPE="${2:-linux}"
JARVIS_URL="https://165.22.1.228"
JARVIS_HOST="jarvis.orbishosting.com"
# Fixed 2026-07-07: jarvis.orbishosting.com on the default port isn't reachable
# from outside the LAN at all (no FortiGate VIP forwards it) — :1972 is the
# confirmed-working external path (same fix as the GitHub webhook and the
# Windows agent installer).
JARVIS_URL="${JARVIS_URL:-http://jarvis.orbishosting.com:1972}"
JARVIS_HOST=""
INSTALL_DIR="/opt/jarvis-agent"
CONFIG_DIR="/etc/jarvis-agent"
STATE_DIR="/var/lib/jarvis-agent"
@@ -38,7 +45,7 @@ mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$STATE_DIR"
# ── Download agent ─────────────────────────────────────────────────────────────
echo "Downloading agent..."
curl -sk -H "Host: $JARVIS_HOST" "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py"
curl -sk "$JARVIS_URL/agent/jarvis-agent.py" -o "$INSTALL_DIR/jarvis-agent.py"
cp "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py
chmod +x "$INSTALL_DIR/jarvis-agent.py" /usr/local/bin/jarvis-agent.py
@@ -50,14 +57,14 @@ else
{
"jarvis_url": "$JARVIS_URL",
"host_header": "$JARVIS_HOST",
"ssl_verify": false,
"ssl_verify": true,
"registration_key": "$REG_KEY",
"hostname": "$HOSTNAME_ARG",
"agent_type": "$AGENT_TYPE",
"poll_interval": 30,
"heartbeat_every": 10,
"update_check_hours": 24,
"watch_services": ["ollama", "homeassistant", "mysql", "mariadb", "nginx", "apache2", "docker"]
"watch_services": []
}
JSONEOF
chmod 600 "$CONFIG_DIR/config.json"
+6 -4
View File
@@ -14,7 +14,7 @@ if (!defined('WEBHOOK_SECRET')) {
exit;
}
define('DEPLOY_QUEUE', '/tmp/jarvis-deploy-queue.txt');
define('DEPLOY_LOG', '/var/www/jarvis/logs/deploy.log');
define('DEPLOY_LOG', '/var/log/jarvis/deploy.log');
header('Content-Type: application/json');
@@ -33,9 +33,10 @@ $repo = $data['repository']['name'] ?? '';
$ref = $data['ref'] ?? '';
$pusher = $data['pusher']['name'] ?? 'unknown';
// Only deploy on pushes to main
if ($ref !== 'refs/heads/main') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not main"]);
// Only deploy on pushes to the repo's actual default branch (master, not main
// this was checking 'main' for a while even though the jarvis repo has always used 'master')
if ($ref !== 'refs/heads/master') {
echo json_encode(['ok' => true, 'skipped' => "ref $ref is not master"]);
exit;
}
@@ -56,3 +57,4 @@ file_put_contents(DEPLOY_QUEUE, $path . "\n", FILE_APPEND | LOCK_EX);
file_put_contents(DEPLOY_LOG, "[$ts] Queued deploy: $repo by $pusher -> $path\n", FILE_APPEND | LOCK_EX);
echo json_encode(['ok' => true, 'queued' => $repo, 'path' => $path]);
// deploy pipeline verified working 2026-07-07