Compare commits

51 Commits

Author SHA1 Message Date
Claude 8399048252 Backup: capture Phase 1/2 env files, systemd drop-ins, and ops scripts
jarvis-backup.sh now also archives /etc/jarvis-arc (reactor.env), /etc/jarvis (db.env), the jarvis-arc.service.d systemd drop-ins, and /usr/local/bin/jarvis-*.sh. Previously a restore would have come back with no API keys or DB password since those moved outside /var/www/jarvis + /opt/jarvis-arc during the secrets sweep.

Verified: ran a real backup (45M) and confirmed all new paths are present in the archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:48:04 -05:00
Claude 652e44f7b3 Add JARVIS health self-check (Phase 2 reliability monitoring)
deploy/jarvis-health.sh (cron */5): detects silent failures the service watchdog cannot — stalled crons (cron.log >10min stale = 2+ missed runs), Arc jobs stuck running >30min, disk >85%, and watchdog service restarts. Writes auto-resolving rows to the alerts table (shown in admin panel) and emails myronblair@gmail.com on any NEW finding via the reactor Gmail SMTP creds.

Verified live end-to-end: injected a fake stuck job -> alert raised + email sent; cleared it -> alert auto-resolved. Installed in root crontab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:43:37 -05:00
Claude 43be3e1105 Stop tracking INFRASTRUCTURE-REFERENCE.md (full credentials doc)
Untracked from git and added to .gitignore. The file stays on disk at public_html/admin/downloads/ (served behind admin auth) and in the jarvis-private + VM110 copies — it is no longer pushed to GitHub going forward.

NOTE: prior revisions remain in GitHub history; a git filter-repo purge + force-push is still pending user sign-off, as are rotations of the secrets that were in it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:35:41 -05:00
Claude 3d16061709 Auth hardening: login rate-limiting + session fixation defenses
- login.php: Redis-backed per-IP rate limit (10 fails / 15 min lockout), keyed off CF-Connecting-IP/X-Forwarded-For so it sees the real client behind NPM; fails open if Redis is down

- login.php: session_regenerate_id(true) on successful auth (prevents session fixation)

- php.ini: session.use_strict_mode = 1 (reject unknown/attacker-supplied session IDs)

- netscan.php: constant-time hash_equals for the registration-key check (matches agent.php)

Cookie flags already HttpOnly + SameSite=Lax (verified live). Agent auth verified: missing/bad X-Agent-Key -> 401 on every machine action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:31:57 -05:00
Claude 80588efa7a Secrets sweep: move hardcoded credentials out of tracked files into env files
Removed live secret literals from git-tracked code (all were on GitHub):

- deploy/reactor.py: Claude/Groq API keys, DB pass, Gmail/iCloud app passwords now from os.environ (loaded via systemd EnvironmentFile=/etc/jarvis-arc/reactor.env, root:www-data 0640)

- public_html/login.php: used a private hardcoded PDO connection; now uses config.php DB_* constants

- deploy/jarvis-backup.sh (runs via cron), jarvis-deploy.sh, jarvis-watchdog.sh: DB pass now sourced from /etc/jarvis/db.env (root:root 0600)

- removed dead agent/jarvis-arc-reactor.py (unreferenced old duplicate leaking an old Groq key + stale Ollama IP)

- added deploy/reactor.env.example and deploy/db.env.example templates

Verified live: reactor restarted with all 21 handlers + DB poller (job round-trip OK), login works, mysqldump auth via env OK.

NOTE: these keys remain in GitHub history and should be rotated (Claude/Groq/Gmail/iCloud/DB). Separate decision needed on INFRASTRUCTURE-REFERENCE.md (full cred doc still tracked) + history purge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:28:42 -05:00
Claude f7309a15fc Rotate agent registration key; remove key literals from public scripts/UI
- install.sh, install-agent.sh: require JARVIS_REG_KEY env var or interactive prompt instead of baked-in key (matches install-mac.sh/install-windows.ps1 behavior)

- netscan.php: reuse AGENT_REGISTRATION_KEY constant instead of a duplicate literal

- agent.php + api.php: add session-authed "regkey" action so the admin install modal fetches the current key at runtime

- jarvis-agents.js: fetch reg key via /api/agent/regkey instead of hardcoding it; pass JARVIS_REG_KEY in the Linux install one-liner

- INFRASTRUCTURE-REFERENCE.md: scrub old key literal (rotated; real value lives only in api/config.php on VM211)

Key rotated on the box + rolled out to all 11 agents (verified all re-register online). New value is in the gitignored api/config.php only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:22:00 -05:00
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
38 changed files with 8771 additions and 10787 deletions
+1
View File
@@ -1,4 +1,5 @@
# Credentials - never commit
public_html/admin/downloads/INFRASTRUCTURE-REFERENCE.md
api/config.php
backup/
+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:
File diff suppressed because it is too large Load Diff
+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');
+5 -1
View File
@@ -54,7 +54,7 @@ function update_agent_seen(string $agentId, string $status = 'online', ?string $
// ── Auth (all actions except register) ───────────────────────────────────────
$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? '';
$browserActions = ['list', 'status', 'myip'];
$browserActions = ['list', 'status', 'myip', 'regkey'];
if ($agentAction !== 'register') {
if (in_array($agentAction, $browserActions)) {
@@ -212,6 +212,10 @@ switch ($agentAction) {
);
agent_ok();
// ── REGKEY (browser: session-authed fetch of registration key) ───────────
case 'regkey':
agent_ok(['registration_key' => AGENT_REGISTRATION_KEY]);
// ── LIST (admin: get all agents status) ──────────────────────────────────
case 'list':
// Mark agents offline if last_seen > 2 minutes ago
+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.');
+2 -2
View File
@@ -2,14 +2,14 @@
// Network scan push endpoint — called by PVE1 cron with nmap results
// Authenticates via X-Registration-Key header (same key as agent installer)
define('NETSCAN_KEY', 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518');
define('NETSCAN_KEY', AGENT_REGISTRATION_KEY);
if ($method !== 'POST') {
echo json_encode(['error' => 'POST only']); exit;
}
$reqKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? '';
if ($reqKey !== NETSCAN_KEY) {
if (!hash_equals(NETSCAN_KEY, $reqKey)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']); exit;
}
+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;
+3
View File
@@ -0,0 +1,3 @@
# Copy to /etc/jarvis/db.env (root:root 0600). Sourced by the root cron scripts
# (jarvis-backup.sh, jarvis-deploy.sh, jarvis-watchdog.sh).
JARVIS_DB_PASS=your-db-password
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
[ -r /etc/jarvis/db.env ] && . /etc/jarvis/db.env
# 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="${JARVIS_DB_PASS:?DB pass unset - see /etc/jarvis/db.env}"
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"
# Phase 1/2 additions: secrets + systemd drop-ins + operational scripts
# (these live outside /var/www/jarvis and /opt/jarvis-arc, so must be
# captured explicitly or a restore comes back with no keys/DB pass).
cp -a /etc/jarvis-arc "$TMPDIR/files/etc/jarvis-arc-etc" 2>>"$LOG" # reactor.env
cp -a /etc/jarvis "$TMPDIR/files/etc/jarvis-etc" 2>>"$LOG" # db.env
cp -a /etc/systemd/system/jarvis-arc.service.d "$TMPDIR/files/etc/jarvis-arc.service.d" 2>>"$LOG"
mkdir -p "$TMPDIR/files/usr-local-bin"
cp -a /usr/local/bin/jarvis-*.sh "$TMPDIR/files/usr-local-bin/" 2>>"$LOG" # health/deploy/watchdog/netscan
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"
+2 -1
View File
@@ -1,4 +1,5 @@
#!/bin/bash
[ -r /etc/jarvis/db.env ] && . /etc/jarvis/db.env
# JARVIS Auto-Deploy Runner — processes GitHub webhook queue every minute.
# Validates PHP syntax before deploying; auto-reverts on bad code.
# Restarts OLS after JARVIS deploys to pick up PHP changes.
@@ -64,7 +65,7 @@ while IFS= read -r path; do
fi
# Insert alert into JARVIS DB
BAD_ESCAPED=$(printf '%s' "$BAD_FILE" | sed "s/'/\\\\\\'/g")
mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se \
mysql -u jarvis_user -p"$JARVIS_DB_PASS" jarvis_db -se \
"INSERT INTO alerts (alert_type,title,message,severity)
VALUES ('deploy_fail','Deploy reverted: syntax error',
'PHP syntax error in $BAD_ESCAPED. Commit $AFTER was reverted and force-pushed to GitHub.','critical');" 2>/dev/null
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# JARVIS Health Self-Check — runs every 5 min via root cron (separate from the
# service watchdog). Detects silent failures the watchdog can't: stalled crons,
# stuck Arc jobs, low disk, and services the watchdog had to restart. Writes
# findings to the `alerts` table (auto-resolving when clear) and emails on any
# NEW finding. Added 2026-07-07 (Phase 2 reliability).
set -u
[ -r /etc/jarvis/db.env ] && . /etc/jarvis/db.env
DB_USER="jarvis_user"; DB_NAME="jarvis_db"
MYSQL=(mysql -u "$DB_USER" -p"${JARVIS_DB_PASS:-}" "$DB_NAME" -N -B -e)
CRONLOG=/var/log/jarvis/cron.log
WDLOG=/var/log/jarvis/watchdog.log
ALERT_TO="myronblair@gmail.com"
REACTOR_ENV=/etc/jarvis-arc/reactor.env
NEW_FINDINGS=""
sql() { "${MYSQL[@]}" "$1" 2>/dev/null; }
esc() { printf '%s' "$1" | sed "s/'/''/g"; }
# Raise (or keep) an alert for a condition. Emails only when it is newly raised.
# $1=source_key $2=severity $3=title $4=message
raise() {
local key sev title msg exists
key=$(esc "$1"); sev=$(esc "$2"); title=$(esc "$3"); msg=$(esc "$4")
exists=$(sql "SELECT COUNT(*) FROM alerts WHERE source_key='$key' AND resolved=0")
if [ "${exists:-0}" = "0" ]; then
sql "INSERT INTO alerts (alert_type,title,message,severity,source_key,auto_resolve,created_at)
VALUES ('health','$title','$msg','$sev','$key',1,NOW())"
NEW_FINDINGS="${NEW_FINDINGS}- [$2] $3: $4"$'\n'
fi
}
# Clear a condition's alert when it's no longer true.
clear_cond() {
local key; key=$(esc "$1")
sql "UPDATE alerts SET resolved=1, resolved_at=NOW()
WHERE source_key='$key' AND resolved=0 AND auto_resolve=1"
}
# 1) Disk usage on / > 85%
DISK=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ "${DISK:-0}" -gt 85 ]; then
raise "health:disk" "critical" "Disk usage high" "Root filesystem at ${DISK}% (threshold 85%)."
else clear_cond "health:disk"; fi
# 2) Arc jobs stuck in 'running' > 30 min
STUCK=$(sql "SELECT COUNT(*) FROM arc_jobs WHERE status='running'
AND COALESCE(started_at, created_at) < NOW() - INTERVAL 30 MINUTE")
if [ "${STUCK:-0}" -gt 0 ]; then
raise "health:arc_stuck" "critical" "Arc jobs stuck" "$STUCK Arc job(s) have been 'running' for over 30 minutes."
else clear_cond "health:arc_stuck"; fi
# 3) Cron stalled — cron.log is written by facts_collector every 3 min. If it
# hasn't changed in 10 min (>2 consecutive missed runs), cron work has stopped.
if [ -f "$CRONLOG" ]; then
AGE=$(( $(date +%s) - $(stat -c %Y "$CRONLOG") ))
if [ "$AGE" -gt 600 ]; then
raise "health:cron" "critical" "Cron jobs stalled" "No cron activity in $((AGE/60)) min (facts_collector runs every 3 min) — crons appear stopped."
else clear_cond "health:cron"; fi
fi
# 4) Watched service restarted by the watchdog in the last 6 min
if [ -f "$WDLOG" ]; then
RECENT=$(awk -v cutoff="$(date -d '6 minutes ago' '+%Y-%m-%d %H:%M:%S')" \
'match($0,/^\[([0-9-]+ [0-9:]+)\]/,m){ if(m[1]>=cutoff && /restarted successfully/) print }' "$WDLOG")
if [ -n "$RECENT" ]; then
SVC=$(printf '%s' "$RECENT" | grep -oE '(nginx|php8.3-fpm|mariadb|redis-server)' | sort -u | tr '\n' ' ')
raise "health:wd_restart:$(date +%Y%m%d%H%M)" "warning" "Service auto-restarted" "Watchdog restarted: ${SVC:-a service}. Investigate why it died."
fi
fi
# Email any NEW findings via the reactor's Gmail SMTP creds.
if [ -n "$NEW_FINDINGS" ]; then
GPASS=""
[ -r "$REACTOR_ENV" ] && GPASS=$(grep -E '^GMAIL_PASS=' "$REACTOR_ENV" | cut -d= -f2-)
if [ -n "$GPASS" ]; then
GMAIL_PASS="$GPASS" ALERT_TO="$ALERT_TO" FINDINGS="$NEW_FINDINGS" python3 - <<'PY'
import os, smtplib, ssl, socket
from email.mime.text import MIMEText
user = "myronblair@gmail.com"
msg = MIMEText("JARVIS health self-check raised new alerts on %s:\n\n%s" % (socket.gethostname(), os.environ["FINDINGS"]))
msg["Subject"] = "JARVIS health alert"
msg["From"] = user; msg["To"] = os.environ["ALERT_TO"]
try:
with smtplib.SMTP("smtp.gmail.com", 587, timeout=20) as s:
s.starttls(context=ssl.create_default_context())
s.login(user, os.environ["GMAIL_PASS"])
s.send_message(msg)
print("health-email: sent")
except Exception as e:
print("health-email: FAILED", e)
PY
else
echo "health-email: no GMAIL_PASS available, skipped"
fi
fi
+2 -1
View File
@@ -1,11 +1,12 @@
#!/bin/bash
[ -r /etc/jarvis/db.env ] && . /etc/jarvis/db.env
# JARVIS Self-Healing Watchdog — runs every 5 min via root cron
# Checks: lsws, mysql, redis, JARVIS HTTP, disk, memory
# Auto-heals: restarts failed services, restarts offline Proxmox VM agents
# Logs to: /home/jarvis.orbishosting.com/logs/watchdog.log
LOG=/home/jarvis.orbishosting.com/logs/watchdog.log
MYSQL="mysql -u jarvis_user -pJ4rv1s_Pr0t0c0l_2026! jarvis_db -se"
MYSQL="mysql -u jarvis_user -p$JARVIS_DB_PASS jarvis_db -se"
TS() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(TS)] $1" >> "$LOG"; }
+7
View File
@@ -0,0 +1,7 @@
# JARVIS Arc Reactor — required secrets. Copy to /etc/jarvis-arc/reactor.env
# (root:www-data 0640), loaded by systemd EnvironmentFile. Not committed.
JARVIS_DB_PASS=your-db-password
CLAUDE_API_KEY=sk-ant-...
GROQ_API_KEY=gsk_...
GMAIL_PASS=your-gmail-app-password
ICLOUD_PASS=your-icloud-app-password
+163 -129
View File
@@ -39,23 +39,24 @@ VERSION = "9.0.0"
DB_HOST = "localhost"
DB_PORT = 3306
DB_USER = "jarvis_user"
DB_PASS = "J4rv1s_Pr0t0c0l_2026!"
DB_PASS = os.environ.get("JARVIS_DB_PASS", "")
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_API_KEY = os.environ.get("CLAUDE_API_KEY", "")
CLAUDE_MODEL = "claude-sonnet-4-6"
GROQ_API_KEY = "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"
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
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"
GMAIL_PASS = os.environ.get("GMAIL_PASS", "")
ICLOUD_USER = "myronblair@icloud.com"
ICLOUD_PASS = "yxfi-yvzu-geqk-japr"
ICLOUD_PASS = os.environ.get("ICLOUD_PASS", "")
# ── LOGGING ───────────────────────────────────────────────────────────────────
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
@@ -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,738 +0,0 @@
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
**Last Updated:** 2026-06-18
**Owner:** Myron Blair — myronblair@outlook.com
---
## TABLE OF CONTENTS
1. [Network Overview](#1-network-overview)
2. [Cloud Servers](#2-cloud-servers)
3. [On-Premise — Proxmox Hypervisors](#3-on-premise--proxmox-hypervisors)
4. [On-Premise — Virtual Machines](#4-on-premise--virtual-machines)
5. [NAS Storage](#5-nas-storage)
6. [Websites (all on DO)](#6-websites--all-on-do)
7. [JARVIS AI System](#7-jarvis-ai-system)
8. [Phone System (FusionPBX)](#8-phone-system-fusionpbx)
9. [Networking & VPN](#9-networking--vpn)
10. [Backup Systems](#10-backup-systems)
11. [SSH Quick Reference](#11-ssh-quick-reference)
12. [Critical Credentials Master List](#12-critical-credentials-master-list)
---
## 1. NETWORK OVERVIEW
```
INTERNET
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
│ (proxied DNS for public sites)
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (6 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)
├─► 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 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
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
│ └── VM 302 10.48.200.99 NetworkBackup
├─► Synology NAS 10.48.200.249 — Media & backup storage
├─► Yealink T48S 10.48.200.2 — Ext 1000 (Myron Blair, Desk)
├─► Yealink T48S 10.48.200.43 — Ext 1001 (Tommy Ivy, Desk)
├─► Yealink AX86R 10.48.200.65 — Ext 1002 (Myron Blair, WiFi Work)
├─► Yealink T57W 10.48.200.3 — External SIP (United Mirror & Glass)
├─► 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)
```
---
## 2. CLOUD SERVERS
### 2A. DigitalOcean — Main Server
| Field | Value |
|-------|-------|
| **IP** | 165.22.1.228 |
| **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 |
**Key Paths:**
- All sites: `/home/<domain>/public_html/`
- Deploy log: per-site (website deploys only)
- Watchdog log: `/usr/local/lsws/logs/watchdog.log`
- Infra repo: `/opt/infra`
**Services running:**
- OpenLiteSpeed web server (`lsws`) — serves all 7 sites
- MySQL 8 — all site databases on localhost
- Redis — session/cache
- PHP 8.5 (`lsphp85`) — runtime for all sites
- Cron jobs: website deploy runner (every 1 min), watchdog (every 5 min)
**CyberPanel Web UI:** `https://165.22.1.228:8090`
Login: `myron / Joker1974!!!`
**phpMyAdmin:** `https://165.22.1.228/phpmyadmin`
Login: `myron / Joker1974!!!`
---
### 2B. FusionPBX / FreeSWITCH — PBX Server
| Field | Value |
|-------|-------|
| **IP** | 134.209.72.226 |
| **OS** | Debian (DigitalOcean droplet) |
| **SSH** | Direct via Tailscale: `ssh root@100.74.46.120` — password: `Joker1974!@#` |
| **Direct SSH** | Only from: 107.178.2.130 / 97.154.109.245 |
| **Purpose** | VoIP phone system — handles all inbound/outbound calls |
**Web UI:** `https://fusion.orbishosting.com`
Login: `admin / fY7XP5swgtpbzrYLhkeVYkA4744`
**Database:** PostgreSQL
User: `fusionpbx` / Password: `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` / Host: 127.0.0.1
**SIP Trunk:** SignalWire
DID: +1 (817) 764-5007
Gateway: `signalwire` on external profile (port 5080, UDP)
**How calls flow:**
```
Caller → SignalWire SIP → FusionPBX:5080 → IVR (ext 900) → Ring extensions
Outbound: Phone → FusionPBX:5080 → SignalWire → PSTN
```
**SSH Relay Command:**
```bash
sshpass -p 'Gonewalk1974!@#' ssh -o StrictHostKeyChecking=no root@165.22.1.228 \
'sshpass -p "Joker1974!@#" ssh -o StrictHostKeyChecking=no root@134.209.72.226 "COMMAND"'
```
---
## 3. ON-PREMISE — PROXMOX HYPERVISORS
### PVE1 — Primary Hypervisor
| Field | Value |
|-------|-------|
| **Local IP** | 10.48.200.90 |
| **External** | orbisne.fortiddns.com (FortiGate DDNS — auto-updates on WAN IP change) |
| **OS** | Proxmox VE 8.x |
| **SSH** | `ssh root@orbisne.fortiddns.com` OR `ssh root@10.48.200.90` — password: `Joker1974!!!` |
| **Web UI** | `https://orbisne.fortiddns.com:8006``root / Joker1974!!!` |
| **Purpose** | Runs VMs 101, 112, 113, 118, 120, 210, CT110 |
**Useful commands:**
```bash
qm list # list all VMs
qm start/stop/restart <VMID> # control VMs
qm guest exec <VMID> -- bash -c "cmd" # run command inside VM (requires QEMU agent)
```
**JARVIS API Token:** `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b`
---
### PVE2 — Secondary Hypervisor
| Field | Value |
|-------|-------|
| **Local IP** | 10.48.200.91 |
| **OS** | Proxmox VE 8.x |
| **SSH** | `ssh root@10.48.200.91` — password: `Joker1974!!!` |
| **Web UI** | `https://10.48.200.91:8006``root / Joker1974!!!` |
| **Purpose** | Runs VM 302 (NetworkBackup); part of shared Proxmox cluster with PVE1 |
---
## 4. ON-PREMISE — VIRTUAL MACHINES
### VM 101 — Home Assistant (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.97 |
| **OS** | Ubuntu + Home Assistant OS/Supervised |
| **Web UI** | `http://orbisne.fortiddns.com:8123``myron / [HA password]` |
| **SSH** | Via HA web terminal only (Settings → Add-ons → Advanced SSH & Web Terminal) |
| **Purpose** | Smart home automation — 212 entities (lights, switches, scenes, sensors) |
| **JARVIS Agent** | ID: `homeassistant_ha` — pushes entity states to JARVIS every 10s |
**JARVIS ↔ HA Integration:**
- HA custom component at `/config/custom_components/jarvis_agent/`
- Pushes all entity state changes to JARVIS `/api/agent/ha_state` (debounced 2s)
- JARVIS admin toggles → queued in `agent_commands` table → HA executes natively
- HA Long-lived Token (Jarvis2): `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE`
---
### VM 112 — Jellyfin Media Server (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.33 |
| **OS** | Ubuntu 22.04 LTS |
| **SSH** | `ssh root@10.48.200.33` — password: `Joker1974!!!` (enabled 2026-06-14) |
| **Web UI** | `http://10.48.200.33:8096` |
| **Purpose** | Media streaming server — Movies and TV shows |
| **JARVIS Agent** | Not yet installed |
**Media Libraries:**
- Movies: `/mnt/mediastack/movies` — NFS from MediaStack (10.48.200.35:/media/movies)
- TV: `/mnt/mediastack/tv` — NFS from MediaStack (10.48.200.35:/media/tv)
**NFS chain:** Jellyfin → MediaStack → Synology NAS (`/volume1/video/movies` and `/volume1/video/tv`)
**Admin token:** `7c0ccf78b91d4b5bafa607f585f24f2d`
**If library scan needed:**
```bash
curl -X POST "http://10.48.200.33:8096/Library/Refresh" \
-H "X-Emby-Token: 7c0ccf78b91d4b5bafa607f585f24f2d"
```
**If NFS stale after MediaStack changes:**
```bash
umount -l /mnt/mediastack/movies && umount -l /mnt/mediastack/tv
mount /mnt/mediastack/movies && mount /mnt/mediastack/tv
```
---
### VM 113 — MediaStack (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.35 |
| **OS** | Ubuntu 24.04 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` |
**Services:**
| Service | Port | Login | API Key |
|---------|------|-------|---------|
| qBittorrent | :8080 | `admin / Joker1974!!!` | — |
| Sonarr | :8989 | `admin / Joker1974!!!` | `b43e04350a594846b4ee95261c29e9e0` |
| Radarr | :7878 | `admin / Joker1974!!!` | `53c4268360444feeae5f98c0cc24e0e3` |
| Prowlarr | :9696 | `admin / Joker1974!!!` | `9d0ce6c5660743b5bf1c7951efc62252` |
**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.
**Media Flow:**
```
IPTorrents (Prowlarr) → Sonarr/Radarr search → qBittorrent download
→ /mnt/nas/video/downloads (NAS)
→ Sonarr/Radarr import → /mnt/nas/video/tv or /mnt/nas/video/movies (NAS)
→ NFS → Jellyfin /mnt/mediastack/movies or /mnt/mediastack/tv
```
**Indexer:** IPTorrents via Prowlarr cookie auth
Cookie: `uid=2237410; pass=JzLP2niTWxBJAZIU3yvtLbJzD55kdLeB`
(Expires — if search fails, log into iptorrents.com, copy uid+pass cookies)
**If Radarr/Sonarr shows "0 active indexers":**
```bash
systemctl stop radarr
sqlite3 /var/lib/radarr/radarr.db "DELETE FROM IndexerStatus WHERE ProviderId=1;"
systemctl start radarr
```
**SSH from DO:**
```bash
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90 \
'ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa root@10.48.200.35 "COMMAND"'
```
---
### VM 118 — Homebridge (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.18 |
| **OS** | Linux |
| **SSH** | `ssh myron@10.48.200.18` — password: `Joker1974!` |
| **Purpose** | Apple HomeKit bridge — exposes non-HomeKit devices to Apple Home app |
| **JARVIS Agent** | ID: `homebridge_b57cbaea` |
---
### VM 120 — NovaCPX Hosting Panel (PVE1)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.110 |
| **OS** | Ubuntu 24.04 LTS |
| **SSH** | `ssh root@10.48.200.110` — password: `Joker1974!!!` (direct, no PVE hop) |
| **Purpose** | Custom web hosting control panel (cPanel alternative), v1.0.27 |
| **JARVIS Agent** | ID: `novacpx_e3b07264` |
**Ports:**
| Port | Panel |
|------|-------|
| :8880 | User panel |
| :8881 | Reseller panel |
| :8882 | Admin panel |
| :8883 | Roundcube webmail |
**Admin:** `https://10.48.200.110:8882``admin / Admin2026!`
**phpMyAdmin:** `http://10.48.200.110/phpmyadmin`
**File Paths:**
- Web root: `/srv/novacpx/public/`
- DB (SQLite): `/var/lib/novacpx/panel.db`
- Config: `/etc/novacpx/config.ini`
- Git repo: `/opt/novacpx-src/`
- GitHub: `myronblair/novacpx` (auto-deploy on push to `main`)
---
### VM 210 — Ollama Local LLM (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 |
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
---
### VM 302 — NetworkBackup (PVE2)
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.99 |
| **OS** | Ubuntu/Linux |
| **SSH** | `ssh myron@10.48.200.99` — password: `Joker1974!` (then `sudo`) |
| **Purpose** | Network backup storage / backup operations |
| **JARVIS Agent** | ID: `networkbackup_NetworkB` |
---
### CT110 — WireGuard Exit Container (PVE1)
| Field | Value |
|-------|-------|
| **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 |
---
## 5. NAS STORAGE
### Synology NAS
| Field | Value |
|-------|-------|
| **IP** | 10.48.200.249 |
| **Login** | `nas / Joker1974!!!` |
| **DSM Web UI** | `http://10.48.200.249:5000` |
| **Purpose** | Primary media and download storage |
**NFS Share:** `/volume1/video` (exported to MediaStack only)
**Directory structure:**
```
/volume1/video/
movies/ ← Radarr imports here; NFS-exported to Jellyfin via MediaStack
tv/ ← Sonarr imports here; NFS-exported to Jellyfin via MediaStack
downloads/ ← qBittorrent downloads here (temp)
incomplete/ ← in-progress torrents
```
**Important:** Synology NFS ACL only allows root to write. All services on MediaStack run as root.
---
## 6. WEBSITES (ALL ON DO)
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)
---
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
| Field | Value |
|-------|-------|
| **URL** | http://jarvis.orbishosting.com:1972 |
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
| **GitHub** | `myronblair/jarvis` |
| **Login** | `myron / Joker1974!!!` |
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
See Section 7 for full JARVIS details.
---
### tomsjavajive.com — Tom's Java Jive
| Field | Value |
|-------|-------|
| **URL** | https://tomsjavajive.com |
| **Path** | `/home/tomsjavajive.com/public_html/` |
| **GitHub** | `myronblair/tomsjavajive` |
| **Purpose** | Coffee shop e-commerce — products, orders, loyalty, wallet, reviews |
| **Admin URL** | `https://tomsjavajive.com/admin/` |
| **Admin Login** | `admin@tomsjavajive.com / Joker1974!!!` OR `myronblair@outlook.com / Joker1974!!!` |
| **DB** | `toms_tjj_db / toms_tjj_user / +60wlPc+55e@gFq4` |
| **Email** | CyberMail API key: `sk_live_7f9b0f9a29f6de31a0d229d4af75d56b094ad724fc58a57d` |
| **Email From** | `noreply@tomsjavajive.com` / `Toms Java Jive` (set in DB settings table) |
---
### epictravelexpeditions.com — Epic Travel Expeditions
| Field | Value |
|-------|-------|
| **URL** | https://epictravelexpeditions.com |
| **Path** | `/home/epictravelexpeditions.com/public_html/` |
| **GitHub** | `myronblair/epictravelexpeditions` |
| **Purpose** | Travel booking / expeditions website |
| **DB** | `epic_travel_db` (see `api/config.php`) |
---
### parkerslingshot.epictravelexpeditions.com — Parker Slingshot (OLD)
| Field | Value |
|-------|-------|
| **URL** | https://parkerslingshot.epictravelexpeditions.com |
| **Path** | `/home/epictravelexpeditions.com/parkerslingshot/` |
| **GitHub** | `myronblair/parkerslingshot` |
| **Purpose** | Old slingshot rental site (superseded by parkerslingshotrentals.com) |
---
### parkerslingshotrentals.com — Parker Slingshot Rentals (LIVE)
| Field | Value |
|-------|-------|
| **URL** | https://www.parkerslingshotrentals.com |
| **Path** | `/home/parkerslingshotrentals.com/public_html/` |
| **GitHub** | `myronblair/parkerslingshotrentals` |
| **Purpose** | Polaris Slingshot rental — bookings, e-signature waiver, admin management |
| **Admin** | `/admin/index.php``admin / Parker2026!` |
| **DB** | `park_slingshot / park_slingshotuser / 4@rxg*8kovxCr7w6` |
| **Square** | Production token: `EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v` |
---
### orbishosting.com — Orbis Hosting (Landing Page)
| Field | Value |
|-------|-------|
| **URL** | https://orbishosting.com |
| **Path** | `/home/orbishosting.com/public_html/` |
| **GitHub** | `myronblair/orbishosting` |
| **Purpose** | Public landing page for Orbis Hosting brand |
---
### orbis.orbishosting.com — Orbis Hosting Portal
| Field | Value |
|-------|-------|
| **URL** | https://orbis.orbishosting.com |
| **Path** | `/home/orbis.orbishosting.com/public_html/` |
| **GitHub** | `myronblair/orbis-hosting-portal` |
| **Purpose** | Customer-facing hosting portal |
---
### tomtomgames.com — TomTom Games
| Field | Value |
|-------|-------|
| **URL** | https://tomtomgames.com |
| **Path** | `/home/tomtomgames.com/public_html/` |
| **GitHub** | `myronblair/tomtomgames` |
| **Purpose** | Gaming website |
| **DB** | `tomtom_games_db` (see config) |
| **Email** | CyberMail API key: `sk_live_7f9b...` |
---
## 7. JARVIS AI SYSTEM
**URL:** http://jarvis.orbishosting.com:1972
**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
### Architecture (end-to-end)
```
Voice (browser mic)
→ SpeechRecognition API
→ Wake phrase: "wake up JARVIS" / "daddy's home"
→ "JARVIS [command]" triggers action
→ /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 2: Groq (cloud, model: compound-beta-mini)
Tier 3: Claude API (Anthropic, fallback)
→ ElevenLabs TTS → browser speaker
```
### Deploy Pipeline
```
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
→ /tmp/jarvis-deploy-queue.txt → /usr/local/bin/jarvis-deploy.sh (cron 1min)
→ git pull + PHP syntax check → deploy or auto-revert
```
Webhook secret: `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1`
### Agent System
Agents installed on all servers — phone home every 10s (heartbeat) / 30s (metrics).
Registration key: `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518`
Install command: `curl -sk http://10.48.200.211/install-agent.sh | bash -s <hostname> <linux|proxmox>`
### Self-Healing Watchdog
`/usr/local/bin/jarvis-watchdog.sh` — runs every 5 min (root cron on DO)
Restarts: lsws, mysql, redis if down
Restarts offline Proxmox VM agents via `qm guest exec`
### Cron Jobs (DO server)
| Schedule | Script | Purpose |
|----------|--------|---------|
| Every 1 min | `jarvis-deploy.sh` | Process GitHub deploy queue |
| Every 3 min | `facts_collector.php` | Collect agent metrics, KB facts, site health |
| Every 5 min | `stats_cache.php` | Weather, news, Proxmox stats refresh |
| Every 5 min | `jarvis-watchdog.sh` | Self-healing: restart dead services |
---
## 8. PHONE SYSTEM (FUSIONPBX)
### Extensions
| Ext | Name | Phone | IP | SIP Password |
|-----|------|-------|----|-------------|
| 1000 | Myron Blair — Desk | Yealink T48S | 10.48.200.2 | `Xk9mPw3nQv7rLs2t` |
| 1001 | Tommy Ivy — Desk | Yealink T48S | 10.48.200.43 | `Tv8xNm4pWq6rZs3k` |
| 1002 | Myron Blair — WiFi Work | Yealink AX86R | 10.48.200.65 | `yXHaJTwa8rj?$GkrVFQB` |
| 1003 | Kitchen | Yealink T57W | 10.48.200.83 | — |
| 1004 | Master Bedroom | Yealink T57W | 10.48.200.85 | — |
| 1010 | Parker County Slingshot | Virtual (voicemail only) | — | — |
| 1011 | Epic Travel Expeditions | Virtual (voicemail only) | — | — |
| 1012 | Tom's Java Jive | Virtual (voicemail only) | — | — |
| 900 | IVR | — | — | (auto-attendant) |
**Phone SIP Settings (all phones):**
- Server: `134.209.72.226`
- Port: `5080`
- Transport: UDP
**Provisioning URL:** `https://fusion.orbishosting.com/app/provision/`
(Username: `provision-master`, Password: `Joker1974!!!`)
### Call Flow
```
Inbound (+18177645007)
→ SignalWire → FusionPBX:5080 (UDP)
→ signalwire-inbound dialplan (catch-all ^.*$)
→ IVR ext 900 (ivr_menu_16k.wav)
→ Routes to extensions 1000/1001/1002/1003/1004
Outbound
→ Phone → FusionPBX:5080
→ signalwire gateway → SignalWire → PSTN
```
### FreeSWITCH CLI Commands
```bash
fs_cli -x "sofia status profile external reg" # check registrations
fs_cli -x "sofia xmlstatus gateway" # check SignalWire gateway
fs_cli -x "reloadxml" # reload config (safe)
fs_cli -x "reloadacl" # reload ACL (safe)
# AVOID: sofia profile external restart (drops all phone registrations)
```
---
## 9. NETWORKING & VPN
### FortiGate Firewall
- WAN IP: 97.154.109.245 (dynamic)
- DDNS: `orbisne.fortiddns.com` (FortiGate auto-updates on IP change)
- Blocks: outbound port 53 (DNS) — MediaStack uses PVE1 dnsmasq (10.48.200.90) as resolver → 100.100.100.100
**Port Forwards:**
| External Port | Internal Destination | Purpose |
|--------------|---------------------|---------|
| :8006 | PVE1:8006 | Proxmox web UI |
| :8123 | HA VM:8123 | Home Assistant |
| :22 | HA VM:22 | HA SSH (unreliable) |
### WireGuard — Jellyfin ↔ MediaStack
- MediaStack runs WireGuard server on `wg1` (port 51820, subnet 10.200.0.1/24)
- Jellyfin peer: 10.200.0.3 (active handshake)
- Used for NFS media file access ONLY — not internet VPN
### 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`
- Required for IPTorrents access (blocks non-VPN IPs)
---
## 10. BACKUP SYSTEMS
### 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)
### 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)
### FusionPBX Backup
- **Repo:** `myronblair/fusionpbx-config`
- **Schedule:** Weekly, Sunday 5am
- **Launcher:** `/usr/local/bin/fusionpbx-backup`
- **Covers:** PostgreSQL dump (gzip, ~29MB) + FreeSWITCH configs
- **Restore:** 10-phase wizard in `restore.sh`
---
## 11. SSH QUICK REFERENCE
```bash
# DO (main web server)
sshpass -p 'Gonewalk1974!@#' ssh -o StrictHostKeyChecking=no root@165.22.1.228
# FusionPBX (must relay via DO)
sshpass -p 'Gonewalk1974!@#' ssh root@165.22.1.228 \
'sshpass -p "Joker1974!@#" ssh root@134.209.72.226 "CMD"'
# PVE1 (direct or via DDNS)
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@orbisne.fortiddns.com
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.90
# PVE2
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.91
# MediaStack (via PVE1)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'ssh -i /root/.ssh/id_rsa root@10.48.200.35 "CMD"'
# Jellyfin (direct, password enabled 2026-06-14)
sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no root@10.48.200.33
# NovaCPX (direct)
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.18 # Homebridge
sshpass -p 'Joker1974!' ssh myron@10.48.200.99 # NetworkBackup
# Run command inside VM via Proxmox (requires QEMU agent installed)
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
'qm guest exec 210 -- bash -c "CMD"'
```
**Password fallback order:** `Joker1974!@#``Joker1974!!!``Joker1974!`
---
## 12. CRITICAL CREDENTIALS MASTER LIST
### SSH / Root Access
| System | User | Password | Notes |
|--------|------|----------|-------|
| DO (165.22.1.228) | root | `Gonewalk1974!@#` | Main web server |
| FusionPBX (134.209.72.226) | root | `Joker1974!@#` | Via DO relay |
| PVE1 (10.48.200.90) | root | `Joker1974!!!` | Also via DDNS |
| PVE2 (10.48.200.91) | root | `Joker1974!!!` | |
| MediaStack (10.48.200.35) | root | key only | Via PVE1 (`/root/.ssh/id_rsa`) |
| Jellyfin (10.48.200.33) | root | `Joker1974!!!` | Enabled 2026-06-14 |
| NovaCPX (10.48.200.110) | root | `Joker1974!!!` | Direct SSH works |
| Ollama / Homebridge / Backup VMs | myron | `Joker1974!` | Then sudo |
### Web Panels & Admin
| System | URL | User | Password |
|--------|-----|------|----------|
| CyberPanel | https://165.22.1.228:8090 | myron | `Joker1974!!!` |
| 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!!!` |
| 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` |
| 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!!!` |
| Prowlarr | http://10.48.200.35:9696 | admin | `Joker1974!!!` |
| Synology NAS | http://10.48.200.249:5000 | nas | `Joker1974!!!` |
| Parker Slingshot Admin | https://parkerslingshotrentals.com/admin | admin | `Parker2026!` |
| TJJ Admin | https://tomsjavajive.com/admin | `admin@tomsjavajive.com` OR `myronblair@outlook.com` | `Joker1974!!!` |
### Databases
| Site | DB Name | DB User | DB Password |
|------|---------|---------|-------------|
| JARVIS | `jarvis_db` | `jarvis_user` | `J4rv1s_Pr0t0c0l_2026!` |
| Tom's Java Jive | `toms_tjj_db` | `toms_tjj_user` | `+60wlPc+55e@gFq4` |
| Parker Slingshot Rentals | `park_slingshot` | `park_slingshotuser` | `4@rxg*8kovxCr7w6` |
| 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` | — | — |
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
### API Keys
| Service | Key |
|---------|-----|
| GitHub PAT | `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` (exp ~2026-08-20) |
| 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` |
| Square (Parker) Production | `EAAAl3FsAu_2ri8kZE_ENEyi2T_C8HXXm5XQFY6Lbnd8SX6FqYp8J_upUeXNYh7v` |
| Square App ID (Parker) | `sq0idp-YSM7BU9IVyOWSzpeP-0nzQ` |
| Webhook HMAC Secret | `4c8805f0285214ff0a0602b5880270b935f36a896946c7f1` |
### SIP / Phone
| Extension | Name | SIP Password |
|-----------|------|-------------|
| 1000 | Myron Blair — Desk (10.48.200.2) | `Xk9mPw3nQv7rLs2t` |
| 1001 | Tommy Ivy — Desk (10.48.200.43) | `Tv8xNm4pWq6rZs3k` |
| 1002 | Myron Blair — WiFi Work (10.48.200.65) | `yXHaJTwa8rj?$GkrVFQB` |
| 1003 | Kitchen (10.48.200.83) | — |
| 1004 | Master Bedroom (10.48.200.85) | — |
| 1010 | Parker County Slingshot (voicemail only) | — |
| 1011 | Epic Travel Expeditions (voicemail only) | — |
| 1012 | Tom's Java Jive (voicemail only) | — |
---
*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."
+19 -5
View File
@@ -1,20 +1,34 @@
#!/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"
STATE_DIR="/var/lib/jarvis-agent"
REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518"
REG_KEY="${JARVIS_REG_KEY:-}"
if [ -z "$REG_KEY" ] && [ -r /dev/tty ]; then
read -rp "Enter JARVIS registration key: " REG_KEY </dev/tty
fi
if [ -z "$REG_KEY" ]; then
echo "ERROR: registration key required (set JARVIS_REG_KEY env var or enter at prompt)" >&2
exit 1
fi
SERVICE_FILE="/etc/systemd/system/jarvis-agent.service"
echo "=== JARVIS Agent Installer v3.0 ==="
@@ -57,7 +71,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:
+7 -2
View File
@@ -19,7 +19,7 @@ $_e1 = $_earlyParts[1] ?? '';
$_skipSession = match(true) {
$_e0 === 'ping' => true,
$_e0 === 'netscan' => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip'], true) => true,
$_e0 === 'agent' && !in_array($_e1, ['list','status','myip','regkey'], true) => true,
default => false,
};
if (!$_skipSession) {
@@ -27,7 +27,12 @@ if (!$_skipSession) {
}
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$_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');
File diff suppressed because it is too large Load Diff
+29 -15
View File
@@ -401,8 +401,8 @@ function renderAgentsTab(agents, metrics) {
style="flex-direction:column;align-items:stretch;border-left:3px solid ${alive ? 'var(--green)' : 'var(--red)'}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<div style="width:8px;height:8px;border-radius:50%;background:${alive ? 'var(--green)' : 'var(--red)'};box-shadow:${alive ? '0 0 6px var(--green)' : 'none'};flex-shrink:0"></div>
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${ag.hostname}</span>
<span style="font-size:0.58rem;color:var(--text-dim)">${ag.agent_type.toUpperCase()} · ${ag.ip_address}</span>
<span style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text);flex:1">${escHtml(ag.hostname)}</span>
<span style="font-size:0.58rem;color:var(--text-dim)">${escHtml(ag.agent_type.toUpperCase())} · ${escHtml(ag.ip_address)}</span>
<span style="font-size:0.58rem;color:${alive ? 'var(--green)' : 'var(--red)'};">${alive ? 'ONLINE' : 'OFFLINE'}</span>
</div>
${alive ? `<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:4px">
@@ -425,21 +425,30 @@ function renderAgentsTab(agents, metrics) {
${svcs ? `<div style="font-size:0.58rem">${svcs}</div>` : ''}
</div>
${alive ? `<div style="display:flex;gap:5px;margin-top:6px">
<button onclick="event.stopPropagation();agentScreenshot('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SCREENSHOT</button>
<button onclick="event.stopPropagation();agentSysinfo('${ag.hostname}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SYSINFO</button>
<button onclick="event.stopPropagation();agentScreenshot('${escJs(ag.hostname)}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--cyan);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SCREENSHOT</button>
<button onclick="event.stopPropagation();agentSysinfo('${escJs(ag.hostname)}')" style="flex:1;background:rgba(0,212,255,0.06);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer"> SYSINFO</button>
</div>` : ''}
</div>`;
}).join('');
}
function openAgentModal() {
async function openAgentModal() {
const os = detectOS();
const title = document.getElementById('agentModalTitle');
const content = document.getElementById('agentModalContent');
const modal = document.getElementById('agentModal');
const regKey = 'f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518';
const baseUrl = 'https://jarvis.orbishosting.com/agent';
let regKey = '<YOUR-REGISTRATION-KEY>';
try {
const rkResp = await fetch('/api/agent/regkey');
if (rkResp.ok) { const rk = await rkResp.json(); if (rk.registration_key) regKey = rk.registration_key; }
} catch (e) { /* not logged in — placeholder stays */ }
const jUrl = window.location.origin;
// Fixed 2026-07-07: this used to be hardcoded to https://jarvis.orbishosting.com/agent,
// which isn't reachable from outside the LAN at all (no FortiGate VIP forwards the
// default port there — confirmed HTTP:000). Using the current page's own origin
// instead means the download link always matches wherever the visitor actually
// reached this dashboard from, LAN or external.
const baseUrl = jUrl + '/agent';
if (os === 'tablet') {
title.textContent = '● JARVIS — TABLET / MOBILE';
@@ -460,9 +469,12 @@ function openAgentModal() {
const inst = {
windows: {
label:'Windows',
cmd:'# Run PowerShell as Administrator:\nSet-ExecutionPolicy Bypass -Scope Process -Force\nInvoke-WebRequest -Uri "'+baseUrl+'/install-windows.ps1" -OutFile "$env:TEMP\\install.ps1"\n& "$env:TEMP\\install.ps1" -JarvisUrl '+jUrl+' -Key '+regKey,
dl: baseUrl+'/install-windows.ps1',
note:'Run PowerShell as Administrator. Installs as a Windows Task Scheduler service.'
// install-windows.ps1 takes no parameters — it reads the reg key from
// $env:JARVIS_REG_KEY and downloads the standalone exe itself (no
// Python/pywin32 needed on the target machine as of the 2026-07-07 rebuild).
cmd:'# Run PowerShell as Administrator:\n$env:JARVIS_REG_KEY=\''+regKey+'\'\nirm '+baseUrl+'/install-windows.ps1 | iex',
dl: baseUrl+'/install-windows.exe',
note:'Run PowerShell as Administrator. Installs as a real Windows Service (auto-starts at boot, no window to keep open).'
},
mac: {
label:'macOS',
@@ -472,15 +484,17 @@ function openAgentModal() {
},
linux: {
label:'Linux',
cmd:'curl -sSL '+baseUrl+'/install.sh | sudo bash -s -- \\\n --jarvis-url '+jUrl+' \\\n --key '+regKey,
// install.sh takes positional args (hostname, agent_type); JARVIS URL and
// registration key come from env vars (key is no longer baked into the script).
cmd:'curl -sSL '+baseUrl+'/install.sh | JARVIS_URL='+jUrl+' JARVIS_REG_KEY=\''+regKey+'\' bash -s -- $(hostname) linux',
dl: baseUrl+'/install.sh',
note:'Run in terminal. Installs as a systemd service.'
note:'Run in terminal (sudo). Installs as a systemd service.'
},
unknown: {
label:'Your System',
cmd:'# Browse installers:\nhttps://jarvis.orbishosting.com/agent/',
dl: 'https://jarvis.orbishosting.com/agent/',
note:'Choose your platform installer from the JARVIS agent directory.'
cmd:'# Couldn\'t detect your OS automatically. Installers are at:\n'+baseUrl+'/install.sh (Linux)\n'+baseUrl+'/install-mac.sh (macOS)\n'+baseUrl+'/install-windows.ps1 (Windows)',
dl: baseUrl+'/install.sh',
note:'Auto-detection didn\'t recognize this browser/OS — pick the matching installer above.'
}
};
const i = inst[os] || inst.unknown;
+22 -8
View File
@@ -1,20 +1,34 @@
#!/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"
REG_KEY="f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518"
REG_KEY="${JARVIS_REG_KEY:-}"
if [ -z "$REG_KEY" ] && [ -r /dev/tty ]; then
read -rp "Enter JARVIS registration key: " REG_KEY </dev/tty
fi
if [ -z "$REG_KEY" ]; then
echo "ERROR: registration key required (set JARVIS_REG_KEY env var or enter at prompt)" >&2
exit 1
fi
SERVICE_FILE="/etc/systemd/system/jarvis-agent.service"
echo "=== JARVIS Agent Installer v3.0 ==="
@@ -38,7 +52,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 +64,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"
+41 -20
View File
@@ -1,30 +1,51 @@
<?php
ini_set('session.cache_limiter', '');
header('Cache-Control: no-store, no-cache, must-revalidate, no-transform');
require_once __DIR__ . '/../api/config.php';
session_start();
if (!empty($_SESSION['jarvis_token'])) { header('Location: /'); exit; }
$error = '';
// ── Login rate limiting (Redis, per client IP) ────────────────────────────────
// Blocks brute force: 10 failed attempts within 15 min -> locked out for 15 min.
$clientIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$clientIp = trim(explode(',', $clientIp)[0]);
$rl = null;
try {
$rl = new Redis();
$rl->connect('127.0.0.1', 6379, 1.5);
} catch (Throwable $e) { $rl = null; } // fail open if Redis is down
$rlKey = 'login_fail:' . $clientIp;
$RL_MAX = 10; $RL_WINDOW = 900;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$u = trim($_POST['username'] ?? '');
$p = $_POST['password'] ?? '';
if ($u && $p) {
$pdo = new PDO('mysql:host=localhost;dbname=jarvis_db;charset=utf8mb4',
'jarvis_user', 'J4rv1s_Pr0t0c0l_2026!',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$row = $pdo->prepare('SELECT * FROM users WHERE username=? LIMIT 1');
$row->execute([$u]);
$user = $row->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($p, $user['password_hash'])) {
$token = bin2hex(random_bytes(32));
$_SESSION['jarvis_token'] = $token;
$_SESSION['jarvis_user_id'] = $user['id'];
$_SESSION['jarvis_name'] = $user['display_name'];
$pdo->prepare('UPDATE users SET last_seen=NOW() WHERE id=?')->execute([$user['id']]);
header('Location: /');
exit;
}
$error = 'ACCESS DENIED';
} else { $error = 'ENTER CREDENTIALS'; }
$fails = ($rl && $rl->exists($rlKey)) ? (int)$rl->get($rlKey) : 0;
if ($fails >= $RL_MAX) {
$error = 'TOO MANY ATTEMPTS — LOCKED';
} else {
$u = trim($_POST['username'] ?? '');
$p = $_POST['password'] ?? '';
if ($u && $p) {
$pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER, DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$row = $pdo->prepare('SELECT * FROM users WHERE username=? LIMIT 1');
$row->execute([$u]);
$user = $row->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($p, $user['password_hash'])) {
if ($rl) $rl->del($rlKey);
session_regenerate_id(true);
$token = bin2hex(random_bytes(32));
$_SESSION['jarvis_token'] = $token;
$_SESSION['jarvis_user_id'] = $user['id'];
$_SESSION['jarvis_name'] = $user['display_name'];
$pdo->prepare('UPDATE users SET last_seen=NOW() WHERE id=?')->execute([$user['id']]);
header('Location: /');
exit;
}
if ($rl) { $rl->incr($rlKey); $rl->expire($rlKey, $RL_WINDOW); }
$error = 'ACCESS DENIED';
} else { $error = 'ENTER CREDENTIALS'; }
}
}
?><!DOCTYPE html>
<html lang="en"><head>
+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