mirror of
https://github.com/myronblair/jarvis
synced 2026-07-28 08:43:00 -05:00
Compare commits
29 Commits
3db329e925
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ee0e116963 | |||
| adbd1a7a24 | |||
| 73aac8ab01 | |||
| 646da21b86 | |||
| d97a345672 | |||
| 141191bcdd | |||
| 8399048252 | |||
| 652e44f7b3 | |||
| 43be3e1105 | |||
| 3d16061709 | |||
| 80588efa7a | |||
| f7309a15fc | |||
| 18783dc137 | |||
| 8f6be645ed | |||
| 185d2c889f | |||
| b5c47d898b | |||
| ffc01c3d4a | |||
| 156b210184 | |||
| 09f73edb0b | |||
| a4336ebdd6 | |||
| e7f555fff1 | |||
| 8034fc67e9 | |||
| 48b2a8523a | |||
| 5ff4f3e311 | |||
| 24b96e809c | |||
| 8567018cc3 | |||
| f8a095f783 | |||
| e060ff0c63 | |||
| 588cfe3f10 |
@@ -1,4 +1,5 @@
|
||||
# Credentials - never commit
|
||||
public_html/admin/downloads/INFRASTRUCTURE-REFERENCE.md
|
||||
api/config.php
|
||||
backup/
|
||||
|
||||
|
||||
+17
-38
@@ -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.
@@ -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("/")
|
||||
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)
|
||||
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
|
||||
return False
|
||||
except Exception as e:
|
||||
log(f"Self-update check failed: {e}")
|
||||
return False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ fi
|
||||
SUBNET="10.48.200.0/24"
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
nmap -sn --send-ip "$SUBNET" 2>/dev/null > "$TMPFILE"
|
||||
nmap -sn "$SUBNET" 2>/dev/null > "$TMPFILE"
|
||||
|
||||
if [ ! -s "$TMPFILE" ]; then
|
||||
echo "$(date): nmap produced no output" >&2
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -182,7 +182,10 @@ function collect_all(): array {
|
||||
}
|
||||
|
||||
// ── Site Health (TTL 5 min) ───────────────────────────────────────────
|
||||
if ($fresh('sites', 300)) {
|
||||
// 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 = [
|
||||
@@ -194,6 +197,11 @@ function collect_all(): array {
|
||||
'orbisportal' => 'https://orbis.orbishosting.com',
|
||||
'tomtomgames' => 'https://tomtomgames.com',
|
||||
];
|
||||
// Sites intentionally gated behind HTTP Basic Auth (e.g. a password-protected
|
||||
// "Coming Soon" page during a rebuild) — treat these status codes as up, not down.
|
||||
$expectedCodes = [
|
||||
'parkerslingshotrentals' => [401],
|
||||
];
|
||||
$down = [];
|
||||
foreach ($sites as $key => $url) {
|
||||
$parsed = parse_url($url);
|
||||
@@ -213,7 +221,8 @@ function collect_all(): array {
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$status = ($code >= 200 && $code < 400) ? 'up' : "down-$code";
|
||||
$ok = ($code >= 200 && $code < 400) || in_array($code, $expectedCodes[$key] ?? [], true);
|
||||
$status = $ok ? 'up' : "down-$code";
|
||||
KBEngine::storeFact('sites', $key, $status, $url, 180);
|
||||
if ($status !== 'up') $down[] = "$key($code)";
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -117,6 +117,65 @@ $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 (2–5 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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -35,6 +35,11 @@ foreach ($devices as $d) {
|
||||
if (!$ip) continue;
|
||||
|
||||
$discoveredIPs[] = $ip;
|
||||
if ($mac) {
|
||||
// Device likely moved to a new IP (DHCP) — drop the stale row so it
|
||||
// doesn't linger as an orphaned duplicate under the old address.
|
||||
JarvisDB::execute('DELETE FROM network_devices WHERE mac=? AND ip<>?', [$mac, $ip]);
|
||||
}
|
||||
JarvisDB::execute(
|
||||
'INSERT INTO network_devices (ip, mac, hostname, status, last_seen)
|
||||
VALUES (?,?,?,?,NOW())
|
||||
|
||||
@@ -213,7 +213,7 @@ if ($weatherAge > 1800) {
|
||||
};
|
||||
|
||||
$weatherRaw = curlGet(
|
||||
'https://wttr.in/FortWorth,TX?format=j1',
|
||||
'https://wttr.in/76088?format=j1',
|
||||
['User-Agent: curl/7.88 Jarvis/1.0'],
|
||||
15
|
||||
);
|
||||
@@ -245,7 +245,7 @@ if ($weatherAge > 1800) {
|
||||
|
||||
cacheStore('weather', [
|
||||
'source' => 'wttr.in',
|
||||
'location' => 'Fort Worth, TX',
|
||||
'location' => 'Weatherford, TX',
|
||||
'current' => [
|
||||
'temp' => (int)($cu['temp_F'] ?? 0),
|
||||
'feels' => (int)($cu['FeelsLikeF'] ?? 0),
|
||||
|
||||
@@ -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
|
||||
Regular → Executable
+24
-4
@@ -1,11 +1,16 @@
|
||||
#!/bin/bash
|
||||
# JARVIS backup — DB dump as tar.gz, admin-panel compatible
|
||||
[ -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="J4rv1s_Pr0t0c0l_2026!"
|
||||
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)
|
||||
@@ -18,9 +23,24 @@ 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
|
||||
tar -czf "$OUTFILE" -C "$TMPDIR" jarvis_db.sql
|
||||
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") ($SIYE)" >> "$LOG"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/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" "warning" "Service auto-restarted" "Watchdog restarted: ${SVC:-a service}. Investigate why it died."
|
||||
else
|
||||
clear_cond "health:wd_restart"
|
||||
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
|
||||
@@ -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"; }
|
||||
|
||||
@@ -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
|
||||
+90
-16
@@ -39,24 +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 = "/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)
|
||||
@@ -130,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
|
||||
raise RuntimeError("All LLM providers failed")
|
||||
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(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}
|
||||
@@ -156,13 +173,36 @@ 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"}
|
||||
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}")
|
||||
@@ -172,8 +212,14 @@ 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", "")
|
||||
|
||||
|
||||
@@ -2785,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,991 +0,0 @@
|
||||
# INFRASTRUCTURE REFERENCE — COMPLETE SYSTEM MAP
|
||||
**Last Updated:** 2026-07-06 (Section 14: added a 4th WiFi extender + corrected Pioneer VSX-822 attribution)
|
||||
**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)
|
||||
13. [Git & Repository Management](#13-git--repository-management)
|
||||
14. [Network Equipment & Client Device Inventory](#14-network-equipment--client-device-inventory)
|
||||
|
||||
---
|
||||
|
||||
## 1. NETWORK OVERVIEW
|
||||
|
||||
```
|
||||
INTERNET
|
||||
│
|
||||
▼
|
||||
[Cloudflare CDN] ──────────────────────────────────────────────────────────────
|
||||
│ (proxied DNS for public sites)
|
||||
│
|
||||
├─► [DigitalOcean 165.22.1.228] — CyberPanel/OLS — All websites (7 sites)
|
||||
│
|
||||
└─► [FusionPBX 134.209.72.226] — FreeSWITCH PBX (SSH via DO relay)
|
||||
|
||||
HOME NETWORK (FortiGate router at 10.48.200.1)
|
||||
WAN: 97.154.109.245 (dynamic, DDNS: orbisne.fortiddns.com)
|
||||
│
|
||||
├─► PVE1 Proxmox 10.48.200.90 (primary hypervisor)
|
||||
│ ├── VM 101 10.48.200.97 Home Assistant
|
||||
│ ├── VM 112 10.48.200.33 Jellyfin
|
||||
│ ├── VM 103 10.48.200.35 MediaStack (Sonarr/Radarr/qBT/Prowlarr)
|
||||
│ ├── VM 118 10.48.200.18 Homebridge
|
||||
│ ├── VM 120 10.48.200.110 NovaCPX hosting panel
|
||||
│ ├── VM 106 10.48.200.210 Ollama (local LLM + vision) — llama3.1:8b, llava:7b
|
||||
│ └── CT110 10.48.200.19/.67 WireGuard exit container (disabled at boot 2026-07-06, legacy/unused)
|
||||
│
|
||||
├─► PVE2 Proxmox 10.48.200.91 (secondary hypervisor)
|
||||
│ └── VM 302 10.48.200.99 NetworkBackup
|
||||
│
|
||||
├─► 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 (7 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 100 — SynchroNet (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **IP** | 10.48.200.112 |
|
||||
| **OS** | Windows |
|
||||
| **Purpose** | SynchroNet BBS (bulletin board system) |
|
||||
| **Note** | VM is named "SynchroNet-50" in Proxmox but its real IP is `.112`, not `.50` — `.50` is an unrelated Raspberry Pi 5 hobby device (see Section 14). Confirmed 2026-07-06. |
|
||||
|
||||
---
|
||||
|
||||
### VM 105 — Nginx Proxy Manager (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **IP** | 10.48.200.200 |
|
||||
| **Purpose** | Reverse proxy management (NPM) |
|
||||
| **Note** | Confirmed 2026-07-06; VM name `NPM-200` matches its IP correctly (unlike SynchroNet/Ollama above). |
|
||||
|
||||
---
|
||||
|
||||
### VM 101 — Home Assistant (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **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 24.04.4 LTS (verified 2026-07-06; previously logged as 22.04 — reinstalled/upgraded at some point) |
|
||||
| **SSH** | `ssh root@10.48.200.33` — password: `Joker1974!!!` (enabled 2026-06-14) |
|
||||
| **Web UI** | `http://10.48.200.33:8096` (Jellyfin 10.11.11) |
|
||||
| **Purpose** | Media streaming server — Movies and TV shows |
|
||||
| **JARVIS Agent** | `jarvis-agent.service` running (verified 2026-07-06) |
|
||||
| **Remote access** | Tailscale installed, node `jellyfin-112` at `100.81.145.48` — used for off-LAN streaming access |
|
||||
|
||||
**Media Libraries:**
|
||||
- Movies: `/mnt/mediastack/movies` — NFS from MediaStack (10.48.200.35:/media/movies)
|
||||
- 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:** `635142c218f2457e813a2de8dec75b05` (regenerated 2026-07-06 — previous token `7c0ccf78...` had gone stale/invalid. The account's real username is **admin**, not myron — confirmed via the `Users` table in `/var/lib/jellyfin/data/jellyfin.db`; it's hidden from the public login list, which is why `/Users/Public` returns empty.)
|
||||
|
||||
**If library scan needed:**
|
||||
```bash
|
||||
curl -X POST "http://10.48.200.33:8096/Library/Refresh" \
|
||||
-H "X-Emby-Token: 635142c218f2457e813a2de8dec75b05"
|
||||
```
|
||||
|
||||
**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 103 — MediaStack (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **IP** | 10.48.200.35 |
|
||||
| **OS** | Ubuntu 24.04.4 LTS |
|
||||
| **SSH** | Via PVE1: `ssh -i /root/.ssh/id_rsa root@10.48.200.35` (no direct access from DO) |
|
||||
| **Purpose** | Automated media download pipeline + NFS server to Jellyfin |
|
||||
| **JARVIS Agent** | ID: `MediaStack_2c00b1b8` |
|
||||
| **Not Docker** | Despite the name, all services below run bare-metal via systemd, not docker-compose |
|
||||
|
||||
**Services:**
|
||||
| Service | Port | Login | API Key |
|
||||
|---------|------|-------|---------|
|
||||
| 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 rotates (US Dallas servers), not a fixed IP (previous "181.214.226.188" was just a snapshot, not stable). All download/general traffic exits via NordVPN with LAN traffic exempted (Kill Switch + Firewall + LAN Discovery all `enabled` as of 2026-07-06 — see Section 9 for the full incident/fix history). If downloads stall, check `nordvpn status` first, then `ip rule show` for rules 32764/32765/table 205.
|
||||
|
||||
**Media Flow:**
|
||||
```
|
||||
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 106 — Ollama Local LLM + Vision (PVE1)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **IP** | 10.48.200.210 |
|
||||
| **OS** | Ubuntu (cloud image) |
|
||||
| **SSH** | `ssh root@10.48.200.210` via PVE1 hop — password: `Joker1974!!!` (also reachable as `ssh myron@10.48.200.210` — password `Joker1974!`, then `sudo`). VM's name is `Ollama-95` but its real IP is `.210`, not `.95` — a prior version of this doc had a stray SSH line pointed at `.95` (nothing listens there); confirmed 2026-07-06 that `.210` is correct. |
|
||||
| **Purpose** | Local AI inference — chat (llama3.1:8b) + vision (llava:7b) |
|
||||
| **API** | `http://10.48.200.210:11434` (Ollama REST API) |
|
||||
| **JARVIS Agent** | ID: `ollama-ai_ubuntu` |
|
||||
| **Models** | `llama3.1:8b` (chat/Tier 1), `llava:7b` (vision cascade) |
|
||||
|
||||
**JARVIS uses this as Tier 1 AI** — if Ollama is down, falls back to Groq (cloud).
|
||||
**Vision cascade:** Arc Reactor calls Claude first; if Claude credits depleted, falls back to llava:7b via Ollama.
|
||||
Vision is enabled via: `/etc/systemd/system/jarvis-arc.service.d/vision.conf` → `OLLAMA_VISION_MODEL=llava:7b`
|
||||
|
||||
---
|
||||
|
||||
### 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 |
|
||||
| **2026-07-06 incident** | MediaStack's `wg0` client for this tunnel (`/etc/wireguard/wg0.conf`) had a `PostUp` hook installing its own iptables kill-switch (`REJECT` all output not via `wg0` or marked `51820`), and `wg-quick@wg0.service` was still **enabled at boot** on MediaStack despite this tunnel being unused — this caused a full LAN/SSH lockout to MediaStack the moment that service came up. **Disabled `wg-quick@wg0` at boot on MediaStack** to prevent recurrence; the tunnel itself and CT110 are untouched. See NordVPN section below for the related (separate) LAN Discovery bug found in the same incident. |
|
||||
|
||||
---
|
||||
|
||||
## 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` and its subpaths `/volume1/video/movies`, `/volume1/video/tv` — all exported to MediaStack (10.48.200.35) only, per `/etc/exports` on the NAS (verified 2026-07-06)
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
/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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` (rotated 2026-07-05 — old PAT `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` was found exposed in `.git/config` on all 6 original sites and must be treated as compromised/revoked)
|
||||
|
||||
---
|
||||
|
||||
### jarvis.orbishosting.com — JARVIS AI Dashboard (MOVED TO PVE1 VM 211)
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | http://jarvis.orbishosting.com (port 80 — old `:1972` reference was wrong, corrected 2026-07-04) |
|
||||
| **Path** | `/var/www/jarvis/ (on JARVIS VM 10.48.200.211)` |
|
||||
| **GitHub** | `myronblair/jarvis` |
|
||||
| **Login** | `myron / Joker1974!!!` |
|
||||
| **Purpose** | Iron Man-style AI home dashboard with voice control, smart home, media, planner |
|
||||
|
||||
See Section 7 for full JARVIS details.
|
||||
|
||||
---
|
||||
|
||||
### worktracking.orbishosting.com — ChuckCo Time Keeper
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **URL** | https://worktracking.orbishosting.com |
|
||||
| **Path** | `/home/worktracking.orbishosting.com/public_html/` |
|
||||
| **GitHub** | `myronblair/chucko` (private) |
|
||||
| **Gitea** | `myron/chucko` (pull-mirror of GitHub) |
|
||||
| **Local clone** | `C:\Users\myron\repos\chucko` on admin Windows machine |
|
||||
| **Purpose** | Work-tracking app for a flat-rate 5-day (Fri–Thu) work week — self-reported hours via personal secret-URL tokens (no login), single shared admin password, phone-friendly screenshot pages for texting workers/payer |
|
||||
| **Admin URL** | `https://worktracking.orbishosting.com/admin/login.php` — password `Joker1974!!!` |
|
||||
| **DB** | `workt_track_db` / `workt_track_user` / `ZWCNMRP2N5NVPsghmve5aRS9` |
|
||||
| **Linked from Blair HQ** | `web.orbishosting.com` dashboard's "Websites" card has direct links to the admin login and the all-workers overview page (site-wide token `972f82cbf7832fdb2cffcdcc84129a4af69e30bd`) |
|
||||
| **Note** | Built 2026-07-05. `includes/config.php` (DB creds, admin password hash, site token) lives outside `public_html`/webroot and is intentionally NOT in the git repo. |
|
||||
| **Admin login rate limiting** | Added 2026-07-06 — `login_attempts` table (`ip_address`, `attempts`, `last_attempt`) in `workt_track_db`; 5 failed attempts locks that IP out for 15 minutes. |
|
||||
| **Code review (2026-07-06)** | 5 findings fixed and deployed: `w.php` mark_paid now rejects any `week_start` that isn't the true current week (was trusting client input); `admin/worker.php` no longer double-HTML-escapes the page title; `all.php` now shows a Paid/Unpaid badge per worker for the displayed week; `s.php`/`p.php`/`all.php` validate the `week` param before building dates (malformed input used to throw an uncaught DateTime exception); admin login rate-limited (see above). |
|
||||
|
||||
---
|
||||
|
||||
### tomsjavajive.com — Tom's Java Jive
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **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...` |
|
||||
|
||||
---
|
||||
|
||||
### Code review pass — all 4 DO-hosted business sites (2026-07-06)
|
||||
Full security/correctness review of tomsjavajive.com, tomtomgames.com, parkerslingshotrentals.com, epictravelexpeditions.com (orbishosting.com apex and orbis.orbishosting.com excluded — not live/do-not-touch per earlier note). 10 findings, all fixed, tested, committed, and pushed to each site's `main` branch same day.
|
||||
|
||||
**Critical (live exploitable, now fixed):**
|
||||
- **tomsjavajive.com `api/orders.php`** had a literal `// Admin check would go here` comment — anyone who knew/found an `order_id` could silently change any order's status (cancelled/delivered/refunded) or overwrite tracking numbers, and read another customer's full order (name, email, address, items), with zero auth. Fixed: `update_status` now requires `AdminAuth::isLoggedIn()`; the `GET ?id=` lookup now requires admin or the order's own customer.
|
||||
- **parkerslingshotrentals.com `uploads/`** — customer driver's license and insurance-card photos were directly downloadable with no login at all (the `Order deny,allow`/`Require all denied` pattern in `uploads/.htaccess` doesn't work on this OpenLiteSpeed setup, same class of gotcha as the `.git` exposure found earlier). Verified live via a throwaway test file before fixing. Fixed with the working `RewriteRule .* - [F,L]` pattern in both the nested `uploads/.htaccess` and the root `.htaccess` — **required an actual `systemctl restart lshttpd`** to take effect (touching `/usr/local/lsws/cgid` alone, which only prevents the cron's own restart trigger, was NOT sufficient for a *new* rewrite rule to be picked up — worth remembering for future `.htaccess` changes on this server).
|
||||
|
||||
**High (fixed):**
|
||||
- **tomsjavajive.com `admin/orders.php`** — the exact "same named PDO param reused twice" bug that already bit `awardPoints()` had recurred in the order search box (`:search` bound once, referenced 3 times), causing a fatal `SQLSTATE[HY093]` on every admin search. Fixed with distinct `:search1`/`:search2`/`:search3`.
|
||||
- **tomtomgames.com `admin/index.php`** — stored XSS: `renderGamerOverview()` inserted username/alias/email into `innerHTML` without the `escHtmlA()` helper used correctly everywhere else in the same file. Alias has no character restriction, so a malicious alias could execute script in an admin's session the moment they open that user's profile. Fixed.
|
||||
|
||||
**Medium (fixed):**
|
||||
- **tomtomgames.com `includes/square.php`** (untracked by git — lives outside `public_html`, fix deployed to the server only) — `charge()`/`refund()` generated a fresh `uniqid()` idempotency key on every call, defeating Square's duplicate-protection entirely. Fixed: keyed off `md5(source_id)` for charges and `md5(payment_id . amount)` for refunds, so retries/double-clicks are recognized as duplicates.
|
||||
- **parkerslingshotrentals.com `contact.php`** — booking availability check + insert had no locking, allowing a double-booking race under concurrent submissions; the deposit-hold idempotency key was suffixed with `time()` (changes every second, so retries aren't deduped). Fixed: wrapped the check+insert in a MySQL `GET_LOCK`/`RELEASE_LOCK` pair scoped to the requested date range, and made the idempotency key stable (`{ref}-dep`, no time suffix).
|
||||
- **epictravelexpeditions.com `api/config.php`** — DB credentials, JWT secret, admin password hash, and mail API key sat in plaintext inside the webroot (protected only by an `.htaccess` rewrite rule, unlike every sibling site where secrets already live outside `public_html`). Relocated to `/home/epictravelexpeditions.com/api-secrets.php` (was already gitignored, so no git history exposure). `.git` itself was already correctly relocated to `git-data/` (just a 49-byte pointer file in the webroot, blocked by `.htaccess`) — no action needed there.
|
||||
|
||||
**Low (fixed):**
|
||||
- **tomtomgames.com `api/purchase.php`** — `logActivity()` referenced undefined `$paymentMethod`/`$amountDollars` (should be `$method`/`$priceCents`) in two places, producing PHP warnings and blank values in the purchase audit log for every transaction. Fixed.
|
||||
- **epictravelexpeditions.com `api/api/testimonials.php`** — the public image-upload endpoint (no login required, by design) had no rate limiting, allowing storage/bandwidth abuse via scripted repeat uploads. Added a `upload_rate_limits` table + per-IP cap (5 uploads/hour).
|
||||
|
||||
---
|
||||
|
||||
## 7. JARVIS AI SYSTEM
|
||||
|
||||
**URL:** http://jarvis.orbishosting.com
|
||||
**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/admin
|
||||
|
||||
### Security hardening (2026-07-06 code review)
|
||||
Full front-end + admin panel review found and fixed 8 issues, 2 of them live critical exposures:
|
||||
- **INFRASTRUCTURE-REFERENCE.md was publicly downloadable with zero auth** (static nginx path bypassed the admin session check entirely). Fixed: file moved to `/var/www/jarvis-private/INFRASTRUCTURE-REFERENCE.md` (owned `www-data:www-data`, mode 640, NOT under `public_html` so nginx never serves it directly), and the DOCS tab now downloads it via a new authenticated `docs_download` action in `admin/index.php` that gates on the existing `loggedIn()` check and streams the file with `readfile()`.
|
||||
- **Two full backup copies of the 5000-line admin panel (`index.php.bak2`, `index.php.bak.<date>`) were sitting in `public_html/admin/` and downloadable with no auth**, exposing the entire admin source/attack surface. Relocated to `/root/jarvis-old-backups/` (no secrets were found in them, so no credential rotation was needed).
|
||||
- `esc()` (the admin panel's JS HTML-escaper) doesn't escape `'`, so it doesn't protect values embedded inside a single-quoted JS string within an `onclick` attribute — HTML-decoding happens before the JS parser sees it. Added a proper `escJs()` helper (backslash + quote + newline escaping, then HTML-escape) and applied it to the Network/Alerts/Intents/Custom-News/Calendar-Feeds edit-modal `onclick` handlers, which were reachable by e.g. any device on the LAN setting a malicious DHCP/mDNS hostname.
|
||||
- Same class of bug on the front-end dashboard (`assets/js/jarvis-app.js`, `assets/js/panels/jarvis-agents.js`): device names and news article titles/sources were inserted into `innerHTML` completely unescaped — a rogue LAN device or a malicious/compromised news feed could inject script that runs with the logged-in session. Added `escHtml()`/`escJs()` helpers directly in `jarvis-app.js` and applied them to device names, VM names, agent hostnames, and news content.
|
||||
- `session.cookie_httponly` was **Off** server-wide (PHP default), meaning the actual session cookie — not just the app's own bearer token — was readable via `document.cookie` from any of the above XSS bugs. Fixed at the PHP-FPM level (`/etc/php/8.3/fpm/php.ini`): `session.cookie_httponly = 1`, `session.cookie_samesite = Lax`, `php8.3-fpm` restarted. Verified live: `Set-Cookie` now includes `HttpOnly; SameSite=Lax`.
|
||||
- `api.php` had `Access-Control-Allow-Origin: *` — tightened to an explicit allow-list of the real JARVIS origin only, with `Access-Control-Allow-Credentials: true` only sent when the origin matches.
|
||||
- Two admin actions (Arc Reactor restart/setup) called an undefined function `k()` instead of the real JSON responder `j()`, causing a PHP fatal error even though the underlying `systemctl` command still fired. Fixed (verified via direct API test — clean `{"ok":true,...}` response now).
|
||||
- Calendar feed passwords were stored and returned in plaintext via `cal_feeds_list`'s `SELECT *`. Changed to return a `has_password` boolean instead of the raw password (the edit UI never actually displayed the password back anyway — write-only field, "leave blank to keep").
|
||||
|
||||
All fixes verified via direct API testing (SSH + curl through the login/action flow) since this doesn't have a staging environment. Pushed to `myronblair/jarvis` master, commit `24bc876`.
|
||||
|
||||
### Functional bugs fixed (2026-07-06)
|
||||
- **"WEB HOST" card on the front dashboard always showed `--%`/offline.** Root cause: the DO server (165.22.1.228) never had the JARVIS monitoring agent installed — every other host in the fleet had one, this one didn't. Installed it with `curl -sk http://10.48.200.211/install-agent.sh | bash -s jarvis-do linux` (hostname arg `jarvis-do` + the DO server's actual machine hostname `orbis` produces the expected `agent_id=jarvis-do_orbis` that `do_server.php` queries for). Also found and fixed two secondary issues hit along the way: the agent's config pointed at a **dead/stale Tailscale peer** (`jarvis-211`, 100.77.178.42, offline 9+ days) instead of the current active one (`jarvis-211-1`, 100.78.153.71) — likely left over from a VM Tailscale re-auth at some point; and a **stale cached API key** in `/var/lib/jarvis-agent/state.json` from a registration attempt that never actually completed server-side, which had to be deleted to force a clean re-registration. Verified live: `do_server` field in `/api/do` now returns real `cpu`/`mem`/`disk`/`online:true` instead of an empty array.
|
||||
- **"WEBSITES" list (part of the same JARVIS SERVER panel) was always empty**, and the KB intent generator's 4-hour "don't run again too soon" guard was potentially never actually throttling correctly. Root cause, found while investigating the above: `api/config.php` sets `date_default_timezone_set('America/Chicago')`, and several places compute "how old is this DB timestamp" via PHP's `time() - strtotime($mysqlDatetimeString)`. Since MySQL's `NOW()`/stored datetimes are naive UTC strings, `strtotime()` under a non-UTC default timezone misinterprets them as being in Chicago time, which throws every such comparison off by the UTC offset (5-6 hours) — in this case making `facts_collector.php`'s freshness gate for the `sites` (and incidentally `proxmox`/`ollama`) categories always look artificially fresh, so the site-health checks that populate the WEBSITES list stopped actually running. Fixed in `facts_collector.php`'s `$fresh()` helper and `kb_intent_generator.php`'s run-guard by moving the elapsed-time comparison entirely into SQL (`updated_at > DATE_SUB(NOW(), INTERVAL ? SECOND)`), which sidesteps PHP timezone handling altogether. Also fixed a leftover cosmetic label (`do_server.php`) still showing `jarvis.orbishosting.com:1972` from before the JARVIS port fix.
|
||||
- **Note for future work**: the `time() - strtotime($dbTimestamp)` anti-pattern appears in a couple of other files (`chat.php`, `email.php`, `planner.php`) but only for *display formatting* of dates, not elapsed-time threshold checks — lower priority, not fixed in this pass, but worth a look if any displayed timestamps look off by a few hours.
|
||||
|
||||
### 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.1:8b) — local LLM
|
||||
Vision: Ollama llava:7b (via Arc Reactor _vision_call cascade)
|
||||
Tier 2: Groq (cloud, model: compound-beta-mini)
|
||||
Tier 3: Claude API (Anthropic, fallback)
|
||||
→ ElevenLabs TTS → browser speaker
|
||||
```
|
||||
|
||||
### Arc Reactor (AI Job Processor)
|
||||
**Service:** `jarvis-arc` (systemd) — port 7474
|
||||
**Runtime:** `/opt/jarvis-arc/` (Python venv, `reactor.py`)
|
||||
**Log:** `/var/log/jarvis/arc.log`
|
||||
**Admin button:** Workers → Daemons → `SETUP` (live popup) / `RESTART`
|
||||
**Vision:** Claude → Ollama llava:7b → graceful fallback
|
||||
**Vision config:** `/etc/systemd/system/jarvis-arc.service.d/vision.conf`
|
||||
|
||||
```bash
|
||||
systemctl status jarvis-arc
|
||||
systemctl restart jarvis-arc
|
||||
journalctl -u jarvis-arc -f
|
||||
```
|
||||
|
||||
To re-deploy Arc Reactor from source:
|
||||
Use **Workers → Daemons → SETUP** in JARVIS admin (live log popup shows progress).
|
||||
|
||||
### Planner: Tasks / Directives / Missions
|
||||
Admin UI sections backed by real schema + live API — verified working 2026-07-02 (created/read/deleted a test row in each, end-to-end).
|
||||
|
||||
**Tasks** — simple to-do list, stored directly in `jarvis_db`.
|
||||
- Table: `tasks` (title, notes, category, priority [urgent/high/normal/low], status [pending/in_progress/done/cancelled], due_date, due_time)
|
||||
- API: `task_list` (GET), `task_save` (POST, **form-encoded**), `task_done` (POST), `task_delete` (POST)
|
||||
|
||||
**Directives** — OKR-style goals with key results, stored directly in `jarvis_db`.
|
||||
- Tables: `directives` (title, description, category, status, priority, target_date) + `directive_key_results` (directive_id, title, current_value, target_value, unit) + `directive_links` (directive_id, link_type, link_id — links a directive to a task/etc.)
|
||||
- API: `directive_list`, `directive_get`, `directive_save` (POST, **JSON body** via `php://input`, id passed as `?id=` query param on update), `directive_delete`
|
||||
|
||||
**Missions** — automation workflows, NOT stored in `jarvis_db` — proxied through **Arc Reactor** (port 7474) which owns the mission state.
|
||||
- Arc Reactor endpoints used by admin: `GET/POST /missions`, `GET /missions/{id}`, `GET /missions/{id}/runs`, `PUT/POST /missions/{id}`, `DELETE /missions/{id}`, `POST /missions/{id}/run`
|
||||
- JARVIS-side mirror tables exist (`missions`, `mission_runs`, `mission_steps`) but the admin panel reads/writes live via Arc Reactor's HTTP API, not directly against these tables
|
||||
- API: `mission_list`, `mission_get`, `mission_runs`, `mission_save` (POST, **JSON body**, id as `?id=` on update), `mission_delete`, `mission_run`, `mission_toggle`
|
||||
- If Arc Reactor is down, these calls return `{"error":"Arc Reactor unreachable"}` — check `systemctl status jarvis-arc` first
|
||||
|
||||
As of 2026-07-02: all three tables are empty (0 rows) — features are fully functional, just unused so far.
|
||||
|
||||
### Deploy Pipeline
|
||||
```
|
||||
Code edit → git push → GitHub webhook → /webhook.php (HMAC verified)
|
||||
→ /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
|
||||
- **Upstream DNS (changed 2026-07-05):** Network → DNS set to "Specify" mode — Primary `1.1.1.1` (Cloudflare), Secondary `8.8.4.4` (Google). Previously defaulted to the router itself (`10.48.200.1`)/ISP-provided servers. Admin: `https://10.48.200.1:9443` — `admin / Joker1974!!!`. Note: `8.8.8.8` specifically showed as "Unreachable" during setup (transient — ISP's own DNS servers were also showing high latency at that moment); `1.1.1.1`/`8.8.4.4` tested healthy and are what's live now.
|
||||
|
||||
**Port Forwards:**
|
||||
| External Port | Internal Destination | Purpose |
|
||||
|--------------|---------------------|---------|
|
||||
| :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: rotating US (Dallas) NordVPN servers, not a fixed IP
|
||||
- Policy routing: table 205 (non-LAN traffic via nordlynx), managed partly by NordVPN itself and partly by a custom `nordvpn-routing.service` unit (`/etc/systemd/system/nordvpn-routing.service`) that adds the `ip rule` for fwmark `0xe1f1` → table 205
|
||||
- Required for IPTorrents access (blocks non-VPN IPs)
|
||||
|
||||
**2026-07-06 incident (fixed):** `nordvpnd` had been crash-looping since ~2026-06-07 (`/var/lib/nordvpn/data/settings.dat` was corrupted/empty) — meaning NordVPN was **not actually protecting MediaStack's traffic for about a month**; downloads were exiting on the plain home IP. Fixed by clearing the corrupt file, restarting the daemon, and re-logging in.
|
||||
|
||||
While fixing this, found NordVPN's own **"LAN Discovery" setting was `disabled`**. With `Routing: enabled` and LAN Discovery off, connecting NordVPN's full-tunnel routing suppresses the main routing table's resolution for anything that would exit via `eth0` — **including same-subnet LAN traffic** — so the entire VM became unreachable (SSH/ping) from the rest of the LAN the moment NordVPN connected. Fixed with `nordvpn set lan-discovery on`, which adds explicit priority rules for private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`) ahead of the tunnel catch-all. Verified LAN reachability holds with Kill Switch + Firewall both re-enabled after this fix.
|
||||
|
||||
**If MediaStack becomes unreachable after a NordVPN reconnect again:** check `nordvpn settings` for `LAN Discovery: enabled` first. If SSH is already dead, the network path itself is unusable — recover via the Proxmox host instead: `qm guest exec 103 -- /bin/bash -c 'export HOME=/root; nordvpn ...'` (guest-exec needs `HOME` set manually or the `nordvpn` CLI fails).
|
||||
|
||||
Current confirmed-good settings (2026-07-06): Firewall `enabled`, Kill Switch `enabled`, Auto-connect `enabled`, LAN Discovery `enabled`, Routing `enabled`.
|
||||
|
||||
---
|
||||
|
||||
## 10. BACKUP SYSTEMS
|
||||
|
||||
### JARVIS Database Backup
|
||||
- **Script:** `/usr/local/bin/jarvis-backup.sh` (also at `/var/www/jarvis/deploy/`)
|
||||
- **Output:** `/var/backups/jarvis/jarvis_backup_TIMESTAMP.tar.gz`
|
||||
- **Log:** `/var/backups/jarvis/backup.log`
|
||||
- **Retention:** 7 days (auto-purge)
|
||||
- **Trigger:** JARVIS admin → Backups → RUN BACKUP NOW, or run script directly
|
||||
- **DB:** `jarvis_db` — `jarvis_user / J4rv1s_Pr0t0c0l_2026!`
|
||||
|
||||
### 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, ~29-60MB) + FreeSWITCH configs
|
||||
- **Restore:** 10-phase wizard in `restore.sh`
|
||||
- **Known issue (found 2026-07-05):** this repo has grown to ~166MB on GitHub / ~196MB on Gitea because the DB dump gets committed directly into git history on every backup run (3 copies in history as of this writing, each ~60MB) rather than being excluded/rotated. Options not yet decided: gitignore the dump going forward, or purge it from history with `git filter-repo` + force-push (destructive, needs explicit sign-off).
|
||||
|
||||
### MSP360 Backup Status (Dashboard Integration)
|
||||
- **Client software:** MSP360 (CloudBerry) Backup CLI installed on all 6 hosts — PVE1, JARVIS (211), NovaCPX (110), Jellyfin (33), MediaStack (35), Homebridge (18)
|
||||
- **Storage target:** `NAS-MSPBackups` destination → Synology NAS CIFS share, mounted at `/mnt/nas-backups/MSPBackups`
|
||||
- **Mount reliability:** `/usr/local/bin/msp360-mount-ensure.sh` (cron `*/15 * * * *` on hosts using the NAS mount) — bind-mounts the MSPBackups subdir onto itself since MSP360's pre-flight `mountpoint` check fails on a subdirectory of a CIFS mount otherwise
|
||||
- **Collector:** `/usr/local/bin/backup-status-collect.sh` on PVE1 (runs via key-trusted root SSH — PVE1 is the only host with passwordless SSH to all 6 targets; other hosts use password auth via `sshpass`)
|
||||
- Queries each host's plan via `cbb plan -l` (legacy v1 CLI — outputs `State:` / `Last result:` fields directly, unlike `cbbV2`/`cbbCommandLineV2` which needs `plan list -b` and different parsing)
|
||||
- Writes `/tmp/backup-status.json`, then `scp`s it to `root@10.48.200.110:/home/webacct/public_html/downloads/backup-status.json`
|
||||
- **Schedule:** daily `0 6 * * *` on PVE1 (`>> /var/log/backup-status-collect.log`)
|
||||
- **Dashboard card:** `web.orbishosting.com` "BACKUP STATUS" card (`index.html`) fetches `/downloads/backup-status.json` client-side (`loadBackupStatus()`), color-codes dots by `result` (green=Success, yellow=Warning, red=Fail, cyan=Running, gray=unknown)
|
||||
- **JSON schema:** `{"updated": "<ISO8601 UTC>", "hosts": [{"name","ip","state","result"}, ...]}`
|
||||
- **Homebridge (2026-07-04): dropped MSP360 entirely.** After extensive troubleshooting (RAM starvation, a bug where its account scanned every other host's shared backup data, missing bind-mount depths, CIFS tuning, a full plan recreation) Homebridge's MSP360 agent kept failing with a false "storage drive not mounted" error at a consistent ~60-75s mark, root cause never conclusively identified (survived every environmental fix, looked like an app-level bug tied to any custom/non-default account path). Since Homebridge (VM 118) was already being backed up successfully every night by the cluster-wide Proxmox vzdump job (`backup-aa6b1890-23c0`, all VMs, 21:00 daily, keep-last=3, to `SynologyProx` storage), MSP360 was stopped/disabled on Homebridge (`systemctl disable msp360-backup.service msp360-backupWA.service`) and removed from the dashboard collector's per-host MSP360 check. The collector now reads Homebridge's status directly from `/mnt/pve/SynologyProx/dump/vzdump-qemu-118-*.vma.zst` on PVE1 instead of querying an in-guest agent.
|
||||
- **Known state (2026-07-04):** 4/5 remaining MSP360 hosts report `Warning`, NovaCPX reports `Fail` — plan-level result, not investigated further; worth checking each host's MSP360 GUI/log for root cause if backups need to be trusted for restore. Homebridge reports `Success` via Proxmox.
|
||||
|
||||
---
|
||||
|
||||
## 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.210 # Ollama (NOT .95 - that's a naming leftover, nothing listens there)
|
||||
sshpass -p 'Joker1974!' ssh myron@10.48.200.18 # Homebridge
|
||||
sshpass -p 'Joker1974!' ssh myron@10.48.200.99 # NetworkBackup
|
||||
|
||||
# Run command inside VM via Proxmox (requires QEMU agent installed; use VMID not IP)
|
||||
sshpass -p 'Joker1974!!!' ssh root@10.48.200.90 \
|
||||
'qm guest exec 106 -- bash -c "export HOME=/root; CMD"' # Ollama = VMID 106
|
||||
# Other VMIDs: Jellyfin=112, MediaStack=103, JARVIS=211, NovaCPX=120, HomeAssistant=101, Homebridge=118
|
||||
```
|
||||
|
||||
**Password fallback order:** `Joker1974!@#` → `Joker1974!!!` → `Joker1974!`
|
||||
|
||||
---
|
||||
|
||||
## 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 | myron | `Joker1974!!!` |
|
||||
| JARVIS Admin | http://jarvis.orbishosting.com/admin | myron | `Joker1974!!!` |
|
||||
| ChuckCo Time Keeper Admin | https://worktracking.orbishosting.com/admin/login.php | — | `Joker1974!!!` |
|
||||
| FusionPBX | https://fusion.orbishosting.com | admin | `fY7XP5swgtpbzrYLhkeVYkA4744` |
|
||||
| Home Assistant | http://orbisne.fortiddns.com:8123 | myron | (HA password) |
|
||||
| NovaCPX Admin | https://10.48.200.110:8882 | admin | `Admin2026!` |
|
||||
| Jellyfin | http://10.48.200.33:8096 | — | token: `635142c218f2457e813a2de8dec75b05` |
|
||||
| qBittorrent | http://10.48.200.35:8080 | admin | `Joker1974!!!` |
|
||||
| Sonarr | http://10.48.200.35:8989 | admin | `Joker1974!!!` |
|
||||
| Radarr | http://10.48.200.35:7878 | admin | `Joker1974!!!` |
|
||||
| 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` | — | — |
|
||||
| ChuckCo Time Keeper | `workt_track_db` | `workt_track_user` | `ZWCNMRP2N5NVPsghmve5aRS9` |
|
||||
| FusionPBX | PostgreSQL | `fusionpbx` | `pSJaF9mUJqPr4Sj5mwJyRqvCCpc` |
|
||||
| MySQL root (DO) | — | root | `b71e5c1a8c7457541b9c1db822de37adfa271926a38b6c20` |
|
||||
|
||||
### API Keys
|
||||
| Service | Key |
|
||||
|---------|-----|
|
||||
| GitHub PAT | `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` (rotated 2026-07-05, scopes `repo`+`workflow`) |
|
||||
| JARVIS Agent Registration | `f846a9aaf7ce9a61742c63c87c4186052a71d2a580c65518` |
|
||||
| Proxmox API Token | `root@pam!jarvis=c45b5feb-f9a9-445d-a626-14fbb959f78b` |
|
||||
| HA Long-lived Token | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIzNmI0N2I1Njk5ZGQ0MTQ2ODMwZWFmYjZiYTQ1MjJkMSIsImlhdCI6MTc4MDIwMzU5NCwiZXhwIjoyMDk1NTYzNTk0fQ.sYRok-jRDlA4lFgWxLQELcEjkJNGQdprk6ZziLwLtXE` |
|
||||
| Sonarr API | `b43e04350a594846b4ee95261c29e9e0` |
|
||||
| Radarr API | `53c4268360444feeae5f98c0cc24e0e3` |
|
||||
| Prowlarr API | `9d0ce6c5660743b5bf1c7951efc62252` |
|
||||
| Jellyfin Admin Token | `635142c218f2457e813a2de8dec75b05` |
|
||||
| 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) | — |
|
||||
|
||||
---
|
||||
|
||||
## 13. GIT & REPOSITORY MANAGEMENT
|
||||
|
||||
**GitHub** (`myronblair`, 26 private repos as of 2026-07-05) is the permanent/source-of-truth storage. **Gitea** (`gitea.orbishosting.com`, hosted on the Synology NAS, login `myron / Joker1974!!!`) mirrors it one-way — GitHub → Gitea only, via pull-mirrors triggered by `POST /api/v1/repos/<owner>/<repo>/mirror-sync`. Never push directly to a Gitea remote.
|
||||
|
||||
**5 repos are Gitea-only by design, never pushed to GitHub** (deliberately kept off a third-party cloud service since they hold real credentials): `fortigate-config`, `infra-private`, `jarvis-secrets`, `msp360-config`, `proxmox-secrets`.
|
||||
|
||||
**Local clones** live on the admin Windows machine at `C:\Users\myron\repos\` (currently `chucko`, `web-dashboard` — more cloned there as needed). Periodic maintenance: `git gc --aggressive --prune=now` to keep loose objects packed.
|
||||
|
||||
**Per-site deploy pattern (the 7 DO-hosted sites + jarvis + web-dashboard):** `.git` metadata is relocated outside the public webroot (e.g. `/home/<site>/git-data` with a `gitdir:` pointer file left in `public_html/.git`) so `.git` itself is never web-accessible, while `git` commands run normally from inside `public_html`. Most sites deploy via a GitHub webhook → queue file → cron puller (~1 min); `chucko` (ChuckCo Time Keeper) currently has no auto-deploy hook — pushes are manual (SSH in, `git pull`/`push` directly).
|
||||
|
||||
**Known repo-hygiene issue:** `fusionpbx-config` bloated to ~166-196MB from repeatedly committing a large DB dump straight into history — see Section 10.
|
||||
|
||||
---
|
||||
|
||||
## 14. NETWORK EQUIPMENT & CLIENT DEVICE INVENTORY
|
||||
|
||||
**Compiled 2026-07-06** from a live ARP scan off PVE1 (~90 hosts), MAC-vendor lookups, and direct confirmation from Myron. Built to support a future VLAN segmentation project — see `VLAN-Segmentation-Plan.docx` in the home folder for the full plan; this section is the durable factual record to carry forward (e.g. into JARVIS) independent of that plan's status.
|
||||
|
||||
### 14.1 Core Network Equipment
|
||||
| Device | Model | Role |
|
||||
|--------|-------|------|
|
||||
| Firewall | **FortiGate 60F** | Primary/active firewall — confirmed the top unit in the rack. A second Fortinet unit is stacked below it; its role is not yet identified — not confirmed as an HA pair. |
|
||||
| Primary switch | **Cisco Catalyst 3560-E Series PoE-48** | 48-port, full PoE, enterprise-managed — full 802.1Q VLAN/trunk support |
|
||||
| Secondary switch | **FortiSwitch 108F-FPOE** | 8-port PoE, FortiLink-managed |
|
||||
| KVM switch | **TRENDnet TK-802R** | Physical console access to rack servers — not networked |
|
||||
| WiFi extender 1 | **TP-Link RE305** — 10.48.200.16 | WiFi clients only, no wired devices |
|
||||
| WiFi extender 2 | **TP-Link RE305** — 10.48.200.89 | WiFi clients only, no wired devices |
|
||||
| WiFi extender 3 | **TP-Link RE305** — 10.48.200.93 | Wired network printers plugged into its Ethernet port; no WiFi clients on this unit |
|
||||
| WiFi extender 4 | Brand unconfirmed (Shenzhen Xunman-branded/OEM) — MAC `fc:22:1c:30:60:14` seen at 10.48.200.100/.64 | Wired Pioneer VSX-822 AV receiver plugged into its Ethernet port; no wireless clients of its own — same bridge-mode pattern as extender 3 above |
|
||||
| Wireless bridge | **Good Story Networks WB610H** — 10.48.200.80 | Links the main house network to the storage shed (a detached building). OEM manufacturer is Shenzhen LiWiFi Technology Co., Ltd (rebranded by Good Story Networks). Plan: once fully deployed, this bridge replaces the need for the RE305 units at .16 and .89 — see the VLAN plan doc for details. VLAN/802.1Q trunk capability not yet confirmed — check before relying on it for segmented WiFi. |
|
||||
|
||||
**Note on consumer extenders and VLANs:** budget range extenders like the RE305 (and likely extender 4 above) generally cannot map multiple SSIDs to separate VLANs over a trunk — they repeat one network. This matters if/when wireless VLAN segmentation is implemented; see the VLAN plan doc's "Wireless VLAN Feasibility" section.
|
||||
|
||||
### 14.2 Client & Peripheral Device Inventory (by category)
|
||||
|
||||
**Printers:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.76 | Epson ET-3750 |
|
||||
| 10.48.200.204 | HP LaserJet 500 Color MFP M570dn (wired to the RE305 at .93) |
|
||||
| 10.48.200.205 | HP LaserJet M1536dnf MFP (wired to the RE305 at .93) |
|
||||
| 10.48.200.201, .202 | Unidentified — likely more printers/peripherals on the same RE305 port, given the pattern above (not yet confirmed) |
|
||||
|
||||
**Storage:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.249 | Synology NAS |
|
||||
| 10.48.200.41 | WD My Cloud — a second NAS alongside the Synology |
|
||||
|
||||
**AV / Entertainment:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.42, .72 | Vizio smart TVs |
|
||||
| 10.48.200.100 | Pioneer VSX-822 AV Receiver — confirmed 2026-07-06, wired into a 4th WiFi range extender/AP (Shenzhen Xunman-branded/OEM, MAC `fc:22:1c:30:60:14`, no wireless clients of its own). This explains the earlier MAC discrepancy cleanly: the extender reports its own MAC in ARP for the wired Pioneer behind it, same bridge-mode pattern seen with the RE305 at `.93` and its wired printers — the Pioneer's own labeled MAC (`74:5E:1C:0E:7C:0B`, genuine Pioneer Corporation OUI) simply never appears on the wire. 10.48.200.64 shares this same extender's MAC — likely the same device (Pioneer or the extender itself) at a prior IP, not a separate device. |
|
||||
|
||||
**Smart home / IoT:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.38 | Samsung SmartThings hub (MAC vendor: Physical Graph Corporation, the original SmartThings company) |
|
||||
| 10.48.200.250, .251 | Goalake Smart Switch 1 and 2 |
|
||||
| 10.48.200.5, .7, .8, .9, .36, .61, .74 | Generic ESP32/ESP8266-based smart plugs/sensors (Espressif chipset) |
|
||||
| 10.48.200.14, .60 | Tuya Smart plugs/switches |
|
||||
| 10.48.200.34 | Bouffalo Lab-chipset IoT device |
|
||||
| 10.48.200.116 | FN-LINK-chipset IoT device |
|
||||
| 10.48.200.6, .10, .15, .23, .24, .27, .28, .30, .31, .32, .37, .62, .82, .86, .87, .105 | TP-Link Tapo smart devices (16 total) |
|
||||
| 10.48.200.17, .39, .40, .46, .53, .68, .71, .106, .115, .118 | Amazon devices (Echo/Fire TV/Kindle) |
|
||||
|
||||
**Security cameras:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.57, .78, .94, .95, .101, .103, .104 | Reolink cameras + NVR (7 addresses total — one of these is the NVR itself, not confirmed which). Cameras are PoE-connected directly to the Catalyst 3560-E, not cabled to the NVR; the NVR pulls streams over the network like any other client. Feeds are viewed both locally on the LAN and remotely via the Reolink app. |
|
||||
| 10.48.200.21, .22 | Ring doorbell/camera — also viewed both locally and via the Ring app remotely |
|
||||
|
||||
**VoIP (Yealink) — see Section 8 for extension details:**
|
||||
`.2, .3, .43, .65, .83, .85`
|
||||
|
||||
**Personal computers / hobby devices:**
|
||||
| IP | Device |
|
||||
|----|--------|
|
||||
| 10.48.200.54 | Apple device (iPhone/iPad/Mac) |
|
||||
| 10.48.200.66 | Dell PC |
|
||||
| 10.48.200.52 | Intel-NIC PC |
|
||||
| 10.48.200.45 | Microsoft device (Surface or Xbox — not yet confirmed which) |
|
||||
| 10.48.200.50 | Raspberry Pi 5 — hobby/tinkering only, no production workload |
|
||||
|
||||
**Still unidentified as of 2026-07-06** (MAC vendor lookup only, no direct confirmation yet):
|
||||
| IP | Vendor signature | Notes |
|
||||
|----|----|----|
|
||||
| 10.48.200.13 | Murata Manufacturing | Embedded WiFi module — device unknown |
|
||||
| 10.48.200.59 | Guangzhou Shiyuan Electronic | Often AV/display equipment — device unknown |
|
||||
| 10.48.200.119 | Macherey-Nagel GmbH & Co. KG | A lab-equipment brand (chromatography/filtration) — unusual on a home network, device unknown |
|
||||
| 10.48.200.77 | Liteon Technology | Could be a PC PSU with network mgmt, or a peripheral — device unknown |
|
||||
| 10.48.200.201, .202 | (shares MAC with the RE305 at .93 and the confirmed printers at .204/.205) | Likely more printers/peripherals, not confirmed |
|
||||
|
||||
**Minor known artifact, low priority:** MAC `7a:e1:7e:44:08:29` is shared across 10.48.200.93 (RE305), .204/.205 (confirmed printers, benign RE305 bridge-mode behavior), and .251 (Goalake Smart Switch 2 — does NOT fit the "wired to the RE305" explanation, so this one specific overlap looks like a genuine duplicate/cloned MAC, common in ultra-budget IoT hardware). Worth a quick check of the Goalake switch's real MAC via its own admin UI at some point; not urgent.
|
||||
|
||||
---
|
||||
|
||||
*This document contains sensitive credentials. Store securely and do not share.*
|
||||
+288
-30
@@ -324,16 +324,35 @@ if ($action) {
|
||||
|
||||
// ── USERS ────────────────────────────────────────────────────────────
|
||||
case 'email_inbox':
|
||||
// Call via server's own IP — REMOTE_ADDR matches JARVIS_IP so auth bypass applies
|
||||
// Fixed 2026-07-07: this used to proxy to the OLD DO-hosted JARVIS
|
||||
// (165.22.1.228/api/email with a spoofed Host header) — a leftover from
|
||||
// before JARVIS moved to this VM (211). That endpoint no longer exists
|
||||
// (404), so the inbox always failed. Reads the local email_triage table
|
||||
// directly now, same source gmail_triage jobs already write to, matching
|
||||
// the pattern email_action_items already used.
|
||||
$acct = $_GET['account'] ?? 'all';
|
||||
$force = !empty($_GET['force']) ? '&force=1' : '';
|
||||
$ch = curl_init('https://165.22.1.228/api/email?account=' . $acct . $force);
|
||||
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>25,
|
||||
CURLOPT_SSL_VERIFYPEER=>false,CURLOPT_SSL_VERIFYHOST=>false,
|
||||
CURLOPT_HTTPHEADER=>['Host: jarvis.orbishosting.com']]);
|
||||
$r = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
|
||||
if($code===200 && $r) j(json_decode($r,true));
|
||||
else j(['error'=>'Email fetch failed (HTTP '.$code.')']);
|
||||
$where = '1=1'; $params = [];
|
||||
if ($acct && $acct !== 'all') { $where .= ' AND account=?'; $params[] = $acct; }
|
||||
$rows = JarvisDB::query(
|
||||
"SELECT account, from_name, from_email, subject, date_received, summary, category
|
||||
FROM email_triage WHERE {$where} ORDER BY date_received DESC LIMIT 100",
|
||||
$params
|
||||
) ?? [];
|
||||
$recent = array_map(function($r) {
|
||||
return [
|
||||
'account' => $r['account'],
|
||||
'from_name' => $r['from_name'],
|
||||
'from_email'=> $r['from_email'],
|
||||
'subject' => $r['subject'],
|
||||
'date' => $r['date_received'],
|
||||
'preview' => $r['summary'],
|
||||
'unread' => false,
|
||||
];
|
||||
}, $rows);
|
||||
$actionCount = JarvisDB::single(
|
||||
"SELECT COUNT(*) AS c FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL"
|
||||
);
|
||||
j(['summary' => ['recent' => $recent], 'action_items_count' => (int)($actionCount['c'] ?? 0)]);
|
||||
|
||||
case 'email_action_items':
|
||||
$rows = JarvisDB::query("SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 100") ?? [];
|
||||
@@ -474,7 +493,7 @@ if ($action) {
|
||||
$latestVersions = [
|
||||
'linux' => '3.1',
|
||||
'proxmox' => '3.1',
|
||||
'windows' => '3.0',
|
||||
'windows' => '3.2',
|
||||
'macos' => '3.0',
|
||||
'homeassistant' => null,
|
||||
];
|
||||
@@ -491,9 +510,9 @@ if ($action) {
|
||||
if (file_exists($cronLog)) {
|
||||
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar\\|intent' " . escapeshellarg($cronLog) . " | tail -60")));
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
|
||||
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
|
||||
if (preg_match('/^\\[(\\d{2}{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
|
||||
if (preg_match('/^\\[?(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]?.*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
|
||||
if (preg_match('/^\\[?(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]?.*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
|
||||
if (preg_match('/^\\[?(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]?.*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
|
||||
if (preg_match('/^\\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\\].*intent/i', $line, $m)) $cronLast['kb_intent_generator'] = $m[1];
|
||||
}
|
||||
}
|
||||
@@ -616,6 +635,56 @@ if ($action) {
|
||||
}
|
||||
j(['lines'=>$out, 'next_line'=>$total]);
|
||||
|
||||
// ── GENERIC WORKER LOG (live-popup support for Facts/Stats/Calendar) ────────
|
||||
case 'worker_log':
|
||||
$wKeywords = [
|
||||
'facts_collector' => '/facts|system:|network:|proxmox:|ha:|do_server:|ollama:|sites:|nmap_scan:/i',
|
||||
'stats_cache' => '/\[cache\]/i',
|
||||
'calendar_sync' => '/calendar/i',
|
||||
];
|
||||
$wid = $_REQUEST['worker_id'] ?? '';
|
||||
if (!isset($wKeywords[$wid])) { bad('Unknown worker'); }
|
||||
$logFile = '/var/log/jarvis/cron.log';
|
||||
$since = max(0, (int)($_GET['since'] ?? 0));
|
||||
if (!file_exists($logFile)) { j(['lines'=>[],'next_line'=>0]); }
|
||||
$allLines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$total = count($allLines);
|
||||
if (!empty($_GET['snapshot'])) { j(['next_line'=>$total]); }
|
||||
$out = [];
|
||||
for ($i = $since; $i < $total; $i++) {
|
||||
if (preg_match($wKeywords[$wid], $allLines[$i])) $out[] = $allLines[$i];
|
||||
}
|
||||
j(['lines'=>$out, 'next_line'=>$total]);
|
||||
|
||||
case 'worker_history':
|
||||
$wKeywords = [
|
||||
'facts_collector' => '/facts|system:|network:|proxmox:|ha:|do_server:|ollama:|sites:|nmap_scan:/i',
|
||||
'stats_cache' => '/\[cache\]/i',
|
||||
'calendar_sync' => '/calendar/i',
|
||||
];
|
||||
$wid = $_REQUEST['worker_id'] ?? '';
|
||||
if (!isset($wKeywords[$wid])) { bad('Unknown worker'); }
|
||||
$logFile = '/var/log/jarvis/cron.log';
|
||||
$result = ['last_success'=>null,'last_success_count'=>null,'failures'=>[]];
|
||||
if (file_exists($logFile)) {
|
||||
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
$cutoff = time() - 7 * 86400;
|
||||
foreach (array_reverse($lines) as $line) {
|
||||
if (!preg_match($wKeywords[$wid], $line)) continue;
|
||||
preg_match('/^\[?(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]?/', $line, $tm);
|
||||
$ts = isset($tm[1]) ? strtotime($tm[1]) : 0;
|
||||
if (!$result['last_success'] && preg_match('/done|complete|collected|synced/i', $line)) {
|
||||
$result['last_success'] = $tm[1] ?? null;
|
||||
}
|
||||
if ($ts >= $cutoff && preg_match('/error|fail/i', $line)) {
|
||||
$result['failures'][] = $line;
|
||||
if (count($result['failures']) >= 20) break;
|
||||
}
|
||||
}
|
||||
$result['failures'] = array_reverse($result['failures']);
|
||||
}
|
||||
j($result);
|
||||
|
||||
// ── KB GENERATOR TOPICS ─────────────────────────────────────────────
|
||||
case 'kb_topics':
|
||||
$where = ['1=1']; $params = [];
|
||||
@@ -787,19 +856,44 @@ if ($action) {
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'failed']);
|
||||
|
||||
// Added 2026-07-07: fetch a single outbox item WITH its body — outbox_list
|
||||
// deliberately omits body for list-view performance, but VIEW needs the full text.
|
||||
case 'outbox_get':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$ch = curl_init('http://127.0.0.1:7474/comms/sent/' . $id);
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'failed']);
|
||||
|
||||
// Added 2026-07-07: closes the "compose creates a draft but nothing can ever
|
||||
// send it" gap — dispatches a queued draft through the real send path.
|
||||
case 'outbox_send':
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||||
$ch = curl_init('http://127.0.0.1:7474/comms/sent/' . $id . '/send');
|
||||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>30, CURLOPT_POST=>true, CURLOPT_POSTFIELDS=>'']);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error'=>'failed']);
|
||||
|
||||
case 'send_reply':
|
||||
// Fixed 2026-07-07: sent 'content', but handle_send_email() reads 'body' —
|
||||
// any edits made to the reply text in the UI were silently discarded and it
|
||||
// always sent the original AI-drafted reply from email_triage instead.
|
||||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing triage id');
|
||||
$content = $_GET['content'] ?? '';
|
||||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['type'=>'send_email','payload'=>['triage_id'=>$id,'content'=>$content],'priority'=>8,'created_by'=>'admin']),
|
||||
CURLOPT_POSTFIELDS => json_encode(['type'=>'send_email','payload'=>['triage_id'=>$id,'body'=>$content],'priority'=>8,'created_by'=>'admin']),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
|
||||
|
||||
case 'compose_email':
|
||||
// Fixed 2026-07-07: this was sending 'recipient'/'subject'/'auto_send', but
|
||||
// reactor.py's handle_compose_email() reads 'to_email'/'subject_hint'/'send' —
|
||||
// every compose dispatch silently produced a draft with blank recipient and
|
||||
// subject, regardless of whether the LLM drafting step itself succeeded.
|
||||
$to = $_GET['to'] ?? ''; if (!$to) bad('Missing recipient');
|
||||
$subject = $_GET['subject'] ?? '';
|
||||
$body = $_GET['body'] ?? '';
|
||||
@@ -807,7 +901,7 @@ if ($action) {
|
||||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode(['type'=>'compose_email','payload'=>['recipient'=>$to,'subject'=>$subject,'instructions'=>$body,'account'=>$account,'auto_send'=>false],'priority'=>7,'created_by'=>'admin']),
|
||||
CURLOPT_POSTFIELDS => json_encode(['type'=>'compose_email','payload'=>['to_email'=>$to,'subject_hint'=>$subject,'instructions'=>$body,'account'=>$account,'send'=>false],'priority'=>7,'created_by'=>'admin']),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
]);
|
||||
$raw = curl_exec($ch); curl_close($ch);
|
||||
@@ -1338,6 +1432,14 @@ a{color:var(--cyan);text-decoration:none}
|
||||
.stat-card .val{font-size:1.6rem;color:var(--cyan)}
|
||||
.stat-card .sub{color:var(--text-dim);font-size:0.65rem;margin-top:4px}
|
||||
.stat-card .ok{color:var(--green)}.stat-card .warn{color:var(--yellow)}.stat-card .danger{color:var(--red)}
|
||||
/* Sites tab: domain names are much longer than typical stat labels (e.g.
|
||||
parkerslingshotrentals.com) — wider columns + no letter-spacing so they read cleanly. */
|
||||
.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;margin-bottom:24px}
|
||||
.site-card{background:var(--surface);border:1px solid var(--border);padding:16px}
|
||||
.site-card .lbl{color:var(--text-dim);font-size:0.68rem;letter-spacing:0.5px;margin-bottom:8px;white-space:normal;word-break:break-word;line-height:1.4}
|
||||
.site-card .val{font-size:1.2rem;color:var(--cyan)}
|
||||
.site-card .sub{color:var(--text-dim);font-size:0.65rem;margin-top:4px}
|
||||
.site-card .ok{color:var(--green)}.site-card .danger{color:var(--red)}
|
||||
|
||||
/* ── TABLE ── */
|
||||
.tbl-wrap{background:var(--surface);border:1px solid var(--border);overflow-x:auto}
|
||||
@@ -2461,6 +2563,114 @@ async function runIntentGenerator() {
|
||||
setTimeout(pollLog, 2000);
|
||||
}
|
||||
|
||||
// Generic live-log popup for simple cron workers (Facts Collector, Stats Cache, Calendar Sync).
|
||||
// Same pattern as runIntentGenerator() above, parameterized instead of duplicated per worker.
|
||||
async function runWorkerWithLog(workerId, label, doneRegex) {
|
||||
const modalHtml = `
|
||||
<div style="font-family:var(--mono);font-size:0.72rem;line-height:1.8">
|
||||
<div id="wl-history" style="background:#0a0f14;border:1px solid var(--border);border-radius:3px;padding:8px 12px;margin-bottom:10px;font-size:0.6rem;color:var(--text-dim)">Loading history...</div>
|
||||
<div id="wl-status" style="color:var(--cyan);margin-bottom:8px;font-size:0.65rem;letter-spacing:1px">LAUNCHING...</div>
|
||||
<div id="wl-log" style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:10px 12px;min-height:160px;max-height:320px;overflow-y:auto;white-space:pre-wrap;font-size:0.62rem;line-height:1.7;color:var(--text-dim)">Waiting for first output...</div>
|
||||
</div>`;
|
||||
|
||||
openModal(`▶ ${label.toUpperCase()}`, modalHtml, null, null);
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
|
||||
const logEl = document.getElementById('wl-log');
|
||||
const statEl = document.getElementById('wl-status');
|
||||
const historyEl = document.getElementById('wl-history');
|
||||
|
||||
let _stop = false, _done = false, lastLine = 0, dotted = 0;
|
||||
|
||||
const stopAndClose = () => {
|
||||
_stop = true;
|
||||
closeModal();
|
||||
if (!_done) wToast(`${label} running in background`);
|
||||
};
|
||||
document.getElementById('modalSave').onclick = stopAndClose;
|
||||
document.getElementById('modalClose').onclick = stopAndClose;
|
||||
|
||||
api('worker_history', {worker_id: workerId}).then(h => {
|
||||
if (!historyEl) return;
|
||||
let html = '';
|
||||
if (h?.last_success) html += `<span style="color:var(--green)">✓ Last success: ${h.last_success}</span>`;
|
||||
else html += '<span style="color:var(--text-dim)">No recorded successes in log</span>';
|
||||
if (h?.failures?.length) {
|
||||
html += `<br><span style="color:var(--red)">⚠ ${h.failures.length} failure line(s) in last 7 days:</span>`;
|
||||
h.failures.slice(-3).forEach(f => {
|
||||
html += `<div style="color:var(--red);opacity:0.7;margin-left:8px">${f.replace(/</g,'<')}</div>`;
|
||||
});
|
||||
}
|
||||
historyEl.innerHTML = html || '<span>No history found</span>';
|
||||
}).catch(() => { if (historyEl) historyEl.textContent = 'History unavailable'; });
|
||||
|
||||
const snap = await api('worker_log', {worker_id: workerId, snapshot:1});
|
||||
lastLine = snap?.next_line ?? 0;
|
||||
|
||||
const kick = await api('worker_action', {worker_type:'cron', worker_id: workerId, waction:'run'});
|
||||
if (!kick || !kick.ok) {
|
||||
statEl.style.color = 'var(--red)';
|
||||
statEl.textContent = 'LAUNCH FAILED: ' + (kick?.error || 'unknown error');
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
return;
|
||||
}
|
||||
statEl.textContent = 'RUNNING — polling log every 3s...';
|
||||
|
||||
async function pollLog() {
|
||||
if (_stop) return;
|
||||
try {
|
||||
const res = await api('worker_log', {worker_id: workerId, since: lastLine});
|
||||
if (res && Array.isArray(res.lines) && res.lines.length) {
|
||||
lastLine = res.next_line;
|
||||
if (logEl.textContent === 'Waiting for first output...') logEl.textContent = '';
|
||||
res.lines.forEach(line => {
|
||||
const div = document.createElement('div');
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail')) div.style.color = 'var(--red)';
|
||||
else if (lower.includes('skip') || lower.includes('warn')) div.style.color = 'var(--yellow)';
|
||||
else if (lower.includes('done') || lower.includes('complete') || lower.includes('ok')) div.style.color = 'var(--green)';
|
||||
else div.style.color = 'var(--text-dim)';
|
||||
div.textContent = line;
|
||||
logEl.appendChild(div);
|
||||
});
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
|
||||
const joined = res.lines.join(' ').toLowerCase();
|
||||
if (doneRegex.test(joined)) {
|
||||
_done = true;
|
||||
statEl.style.color = 'var(--green)';
|
||||
statEl.textContent = 'COMPLETE';
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
return;
|
||||
}
|
||||
dotted = 0;
|
||||
} else {
|
||||
dotted++;
|
||||
if (dotted < 30) {
|
||||
const waitDiv = document.createElement('div');
|
||||
waitDiv.style.color = 'rgba(0,212,255,0.3)';
|
||||
waitDiv.textContent = '· waiting for output' + '.'.repeat(dotted % 4);
|
||||
const last = logEl.lastChild;
|
||||
if (last && last.textContent && last.textContent.startsWith('· waiting')) {
|
||||
logEl.replaceChild(waitDiv, last);
|
||||
} else {
|
||||
if (logEl.textContent === 'Waiting for first output...') logEl.textContent = '';
|
||||
logEl.appendChild(waitDiv);
|
||||
}
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
} else {
|
||||
statEl.style.color = 'var(--yellow)';
|
||||
statEl.textContent = 'RUNNING IN BACKGROUND (check cron log for results)';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
if (!_stop) setTimeout(pollLog, 3000);
|
||||
}
|
||||
|
||||
setTimeout(pollLog, 2000);
|
||||
}
|
||||
|
||||
let _topicsData = [];
|
||||
let _topicsCats = [];
|
||||
|
||||
@@ -2870,8 +3080,22 @@ async function loadWorkers() {
|
||||
}
|
||||
// Cron Workers
|
||||
const cl=d.cron_last||{};
|
||||
const LIVE_LOG_WORKERS = {
|
||||
facts_collector: /done|collected/,
|
||||
stats_cache: /done/,
|
||||
calendar_sync: /done/,
|
||||
};
|
||||
document.getElementById('workers-crons').innerHTML=CRON_DEFS.map(c=>{
|
||||
const runBtn=!c.norun?`<button onclick="workerAction('cron','${c.id}','run')" style="${wBtn('cyan')}">▶ RUN</button>`:'<span class="ts">—</span>';
|
||||
let runBtn;
|
||||
if (c.norun) {
|
||||
runBtn = '<span class="ts">—</span>';
|
||||
} else if (c.id === 'kb_intent_generator') {
|
||||
runBtn = `<button onclick="runIntentGenerator()" style="${wBtn('cyan')}">▶ RUN</button>`;
|
||||
} else if (LIVE_LOG_WORKERS[c.id]) {
|
||||
runBtn = `<button onclick="runWorkerWithLog('${c.id}','${c.label}',${LIVE_LOG_WORKERS[c.id]})" style="${wBtn('cyan')}">▶ RUN</button>`;
|
||||
} else {
|
||||
runBtn = `<button onclick="workerAction('cron','${c.id}','run')" style="${wBtn('cyan')}">▶ RUN</button>`;
|
||||
}
|
||||
return `<tr>
|
||||
<td><strong>${c.label}</strong></td>
|
||||
<td class="ts">${c.schedule}</td>
|
||||
@@ -3348,18 +3572,18 @@ async function loadSites() {
|
||||
if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; }
|
||||
const labels = {
|
||||
'jarvis':'jarvis.orbishosting.com','tomsjavajive':'tomsjavajive.com',
|
||||
'epictravelexp':'epictravelexpeditions.com','parkersling':'parkerslingshot',
|
||||
'epictravelexp':'epictravelexpeditions.com','parkerslingshotrentals':'parkerslingshotrentals.com',
|
||||
'orbishosting':'orbishosting.com','orbisportal':'orbis.orbishosting.com','tomtomgames':'tomtomgames.com'
|
||||
};
|
||||
let cards = sites.map(s => {
|
||||
const up = s.fact_value==='up';
|
||||
return `<div class="stat-card" style="border-left:3px solid ${up?'var(--green)':'var(--red)'}">
|
||||
return `<div class="site-card" style="border-left:3px solid ${up?'var(--green)':'var(--red)'}">
|
||||
<div class="lbl">${esc(labels[s.fact_key]||s.fact_key)}</div>
|
||||
<div class="val ${up?'ok':'danger'}" style="font-size:1.2rem">${up?'ONLINE':'OFFLINE'}</div>
|
||||
<div class="val ${up?'ok':'danger'}">${up?'ONLINE':'OFFLINE'}</div>
|
||||
<div class="sub">checked ${ago(s.updated_at)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
document.getElementById('sites-content').innerHTML = `<div class="stat-grid">${cards}</div>`;
|
||||
document.getElementById('sites-content').innerHTML = `<div class="site-grid">${cards}</div>`;
|
||||
}
|
||||
|
||||
// ── USERS ─────────────────────────────────────────────────────────────────────
|
||||
@@ -4190,6 +4414,7 @@ async function loadOutbox() {
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${m.account||'gmail'}</td>
|
||||
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${sc==='queued' ? `<button class="btn btn-xs btn-green" onclick="outboxSend(${m.id})">SEND</button>` : ''}
|
||||
<button class="btn btn-xs" onclick="outboxViewBody(${m.id})">VIEW</button>
|
||||
<button class="btn btn-xs btn-red" onclick="outboxDelete(${m.id})">DEL</button>
|
||||
</td>
|
||||
@@ -4205,12 +4430,19 @@ async function outboxDelete(id) {
|
||||
else toast('Delete failed: ' + (d.error||''), 'err');
|
||||
}
|
||||
|
||||
// Added 2026-07-07: composing only ever created a queued draft — there was no
|
||||
// button anywhere to actually send one. This closes that gap.
|
||||
async function outboxSend(id) {
|
||||
if (!confirm('Send this draft now?')) return;
|
||||
const d = await api('outbox_send', {id});
|
||||
if (d.success) { toast('Sent', 'ok'); loadOutbox(); }
|
||||
else toast('Send failed: ' + (d.error||'unknown error'), 'err');
|
||||
}
|
||||
|
||||
async function outboxViewBody(id) {
|
||||
const d = await api('outbox_list', {limit: 200});
|
||||
const sent = Array.isArray(d) ? d : (d.sent || []);
|
||||
const m = sent.find(x => x.id == id);
|
||||
if (!m) return;
|
||||
openModal('SENT MESSAGE — ' + esc(m.subject||''), `
|
||||
const m = await api('outbox_get', {id});
|
||||
if (!m || m.error) { toast('Failed to load message', 'err'); return; }
|
||||
openModal((m.status==='queued'?'DRAFT — ':'SENT MESSAGE — ') + esc(m.subject||''), `
|
||||
<div style="font-family:var(--mono);font-size:0.65rem;color:var(--text-dim);margin-bottom:8px">TO: ${esc(m.to_email||'')} · ACCOUNT: ${m.account||'gmail'}</div>
|
||||
<pre style="font-size:0.65rem;white-space:pre-wrap;max-height:300px;overflow-y:auto;background:rgba(0,212,255,0.03);border:1px solid var(--border);border-radius:3px;padding:8px">${esc(m.body||'(no body)')}</pre>
|
||||
`, null, null);
|
||||
@@ -4226,6 +4458,7 @@ function outboxCompose() {
|
||||
<input id="oc-to" class="inp" placeholder="To: email address" type="email">
|
||||
<input id="oc-subject" class="inp" placeholder="Subject">
|
||||
<textarea id="oc-body" class="inp" rows="5" style="resize:vertical" placeholder="Describe what to say — AI will draft the full message"></textarea>
|
||||
<div id="oc-status" style="display:none;font-size:0.65rem;color:var(--text-dim);padding:8px;background:#060a0e;border:1px solid var(--border);border-radius:3px"></div>
|
||||
</div>
|
||||
`, async () => {
|
||||
const to = document.getElementById('oc-to')?.value.trim();
|
||||
@@ -4233,14 +4466,39 @@ function outboxCompose() {
|
||||
const body = document.getElementById('oc-body')?.value.trim();
|
||||
const account = document.getElementById('oc-account')?.value;
|
||||
if (!to || !body) { toast('Please fill To and message description', 'err'); return; }
|
||||
const statusEl = document.getElementById('oc-status');
|
||||
const d = await api('compose_email', {to, subject, body, account});
|
||||
if (d.job_id) {
|
||||
toast('Compose job dispatched — Job #' + d.job_id, 'ok');
|
||||
if (!d.job_id) { toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err'); return; }
|
||||
// Fixed 2026-07-07: this used to toast "dispatched" and close immediately, with
|
||||
// no way to ever find out if the job later failed — it just silently vanished.
|
||||
// Now polls the actual job status until it finishes, so failures are visible.
|
||||
if (statusEl) { statusEl.style.display = 'block'; statusEl.textContent = 'Drafting — Job #' + d.job_id + '...'; }
|
||||
document.getElementById('modalSave').disabled = true;
|
||||
const jobId = d.job_id;
|
||||
const deadline = Date.now() + 120000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
const j = await api('arc_job_get', {id: jobId}).catch(() => null);
|
||||
if (!j || j.error) continue;
|
||||
if (j.status === 'done') {
|
||||
toast('Draft ready: ' + (j.result?.subject || '(no subject)'), 'ok');
|
||||
closeModal();
|
||||
setTimeout(loadOutbox, 5000);
|
||||
} else {
|
||||
toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err');
|
||||
loadOutbox();
|
||||
return;
|
||||
} else if (j.status === 'failed') {
|
||||
if (statusEl) {
|
||||
statusEl.style.color = 'var(--red)';
|
||||
statusEl.textContent = '✗ Failed: ' + (j.error || 'unknown error');
|
||||
}
|
||||
document.getElementById('modalSave').disabled = false;
|
||||
document.getElementById('modalSave').textContent = 'CLOSE';
|
||||
document.getElementById('modalSave').onclick = closeModal;
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Drafting — Job #' + jobId + ' (' + j.status + ')...';
|
||||
}
|
||||
if (statusEl) { statusEl.style.color = 'var(--yellow)'; statusEl.textContent = '⚠ Still running in background — check outbox shortly.'; }
|
||||
document.getElementById('modalSave').disabled = false;
|
||||
}, 'DISPATCH');
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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 ==="
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
ad9b59c09e5862c5abc35f73999aa2666f8401817b053c385505fa420ca473e7
|
||||
@@ -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
|
||||
|
||||
# ── 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("/")
|
||||
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)
|
||||
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:
|
||||
global _update_restart
|
||||
_update_restart = True
|
||||
# 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
|
||||
return False
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -982,6 +982,7 @@ async function loadWeather() {
|
||||
const d = await api('weather');
|
||||
if (!d || !d.current) return;
|
||||
const c = d.current;
|
||||
if (d.location) document.getElementById('weather-loc').textContent = d.location.toUpperCase();
|
||||
document.getElementById('weather-temp').textContent = c.temp;
|
||||
document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase();
|
||||
document.getElementById('weather-feels').textContent = c.feels + '°F';
|
||||
@@ -1631,11 +1632,18 @@ async function checkAgentStatus() {
|
||||
sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE';
|
||||
const cnt = document.getElementById('net-agent-count');
|
||||
if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE';
|
||||
// Fixed 2026-07-07: this used to fall back to "any agent on the same /24
|
||||
// subnet" when the exact IP didn't match, meant to handle NAT — but when
|
||||
// a LAN client hits the dashboard via its external hostname (hairpin NAT,
|
||||
// or through Cloudflare), the server sees the SAME single reflected/proxy
|
||||
// IP for every visitor on the LAN, not each machine's real one. The exact
|
||||
// match then always fails, and the subnet fallback just grabbed whichever
|
||||
// agent happened to be first in the list — showing a *different real
|
||||
// machine* as "yours", not a harmless guess. Exact match only now; if it
|
||||
// fails, the button correctly falls through to "not detected" instead of
|
||||
// reporting someone else's agent as this one.
|
||||
const myIp = data.my_ip || '';
|
||||
// Match by exact IP first, then by same /24 subnet (handles NAT behind same router)
|
||||
const mySubnet = myIp.split('.').slice(0,3).join('.');
|
||||
_myAgent = online.find(a => a.ip_address === myIp)
|
||||
|| online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.'));
|
||||
_myAgent = online.find(a => a.ip_address === myIp) || null;
|
||||
_agentOnline = !!_myAgent;
|
||||
if (btn) {
|
||||
const isTablet = detectOS() === 'tablet';
|
||||
|
||||
@@ -432,14 +432,23 @@ function renderAgentsTab(agents, metrics) {
|
||||
}).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;
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<div id="leftPanel">
|
||||
<!-- Weather Widget -->
|
||||
<div class="panel" style="flex:0 0 auto">
|
||||
<div class="panel-title">WEATHER <span id="weather-loc" style="font-size:0.55rem;color:var(--text-dim)">FORT WORTH, TX</span></div>
|
||||
<div class="panel-title">WEATHER <span id="weather-loc" style="font-size:0.55rem;color:var(--text-dim)">WEATHERFORD, TX</span></div>
|
||||
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:8px">
|
||||
<div style="flex:1">
|
||||
<div style="display:flex;align-items:baseline;gap:8px">
|
||||
|
||||
@@ -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,7 +64,7 @@ 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",
|
||||
|
||||
+23
-2
@@ -1,20 +1,39 @@
|
||||
<?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') {
|
||||
$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=localhost;dbname=jarvis_db;charset=utf8mb4',
|
||||
'jarvis_user', 'J4rv1s_Pr0t0c0l_2026!',
|
||||
$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'];
|
||||
@@ -23,9 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
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>
|
||||
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user