diff --git a/Plugin/unraid/Varaverk.page b/Plugin/unraid/Varaverk.page index d3da024..0bb5fbc 100644 --- a/Plugin/unraid/Varaverk.page +++ b/Plugin/unraid/Varaverk.page @@ -74,10 +74,17 @@ unset($_master, $_h1m, $_host1_blank, $_my_hostid, $_conf_missing); $tab = $_GET['tab'] ?? 'monitor'; $validTabs = ['monitor', 'scheduler', 'docker', 'watchdog', 'partnership', 'fallback', 'arrs', 'rsync', 'auth', 'settings']; -// The AI tab exists only while AI_ENABLED is true. Appended to $validTabs rather than filtered -// out of it, so the check below rejects ?tab=ai server-side as well — omitting the link is -// presentation, not access control, and api/ai.php refuses `ask` on the same flag independently. -$_vv_ai = strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) === 'true'; +// The AI tab exists only on HOST1, and only while AI_ENABLED is true. Appended to $validTabs +// rather than filtered out of it, so the check below rejects ?tab=ai server-side as well — +// omitting the link is presentation, not access control, and api/ai.php refuses every action +// on the same two conditions independently. +// +// The host half is not a preference: HOST1 owns the GPU, the Ollama process and the index, and +// include/ai.php only ever reads the *local* {HOST}_OLLAMA_URL — there is no Tailscale resolver +// in the PHP layer the way there is in the shell. On any other host the tab could only render +// and then fail its own health check. +$_vv_ai = vv_is_ai_host() + && strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) === 'true'; if ($_vv_ai) $validTabs[] = 'ai'; if (!in_array($tab, $validTabs)) $tab = 'monitor'; diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index a6b3cdf..a14e53c 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -30,9 +30,10 @@ // its token, so the token is not guessable. // // OPERATIONAL SAFEGUARDS -// ask is refused when AI is disabled. -// AI_ENABLED gates the whole subsystem; the tab is hidden when it is false, but hiding -// a link is not access control and the endpoint is reachable directly. +// Every action is refused off HOST1, and ask is refused when AI is disabled. +// AI_ENABLED gates the whole subsystem and vv_is_ai_host() gates the node; the tab is +// hidden when either fails, but hiding a link is not access control and the endpoint is +// reachable directly. The host check sits ahead of the dispatch and answers 404. // // Every token is validated as hex before it composes a path. // vv_ai_job_path() returns null for anything else, and each caller checks. That pattern @@ -117,6 +118,16 @@ if ($action !== 'poll') { $_SERVER['REMOTE_ADDR'] ?? '?')); } +// Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab +// on any host but HOST1, but a hidden link is not access control and this endpoint is reachable +// directly. Every action is refused rather than just the expensive ones — there is no such thing +// as a read this host is entitled to, since the index and the model are not here. +if (!vv_is_ai_host()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']); + exit; +} + // ── stats ───────────────────────────────────────────────────────────────────── if ($action === 'stats') { echo json_encode(['ok' => true, 'stats' => vv_ai_stats()]); diff --git a/Plugin/unraid/include/config.php b/Plugin/unraid/include/config.php index 9e03da7..1db397c 100644 --- a/Plugin/unraid/include/config.php +++ b/Plugin/unraid/include/config.php @@ -43,7 +43,8 @@ // Shared config still resolves; host-specific values are simply absent. // // EXPORTS -// Identity vv_detect_host(), vv_get_hostname(), vv_is_owner(), vv_known_hosts() +// Identity vv_detect_host(), vv_get_hostname(), vv_is_owner(), vv_is_ai_host(), +// vv_known_hosts() // Config vv_conf_vars(), vv_read_conf_raw(), vv_write_conf_raw(), vv_get_conf_files() // Parsing vv_parse_conf_scalar(), vv_parse_kv_db(), vv_format_uptime() // Remote vv_resolve_tailscale_ip(), vv_remote_state_cmd(), vv_local_ip() @@ -257,6 +258,16 @@ function vv_is_owner(): bool { return vv_detect_host() === 'host1'; } +// The AI subsystem is HOST1-only: it is the node with the GPU, the Ollama process and the +// index. Deliberately a separate predicate from vv_is_owner() even though both resolve to +// host1 today — one says "auth source of truth", this one says "AI runs here", and the day +// either moves, conflating them would move the other by accident. +// +// Returns false for 'unknown', so a host that cannot identify itself never shows the tab. +function vv_is_ai_host(): bool { + return vv_detect_host() === 'host1'; +} + function vv_read_conf_raw(string $filename): string { $path = CONF_DIR . '/' . $filename; return file_exists($path) ? file_get_contents($path) : ''; diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index a26f9a6..190cc3d 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -9,9 +9,11 @@ // api/ai.php, receives a token, and polls until the job reaches done or error. That keeps // the api layer on one response convention; see api/ai.php for why SSE was declined. // -// The tab is only reachable when AI_ENABLED is true. Varaverk.page omits it from the tab -// list and rejects it server-side, and api/ai.php refuses ask independently — hiding a link -// is not access control. +// The tab is only reachable on HOST1, and only when AI_ENABLED is true. Varaverk.page omits +// it from the tab list and rejects it server-side, and api/ai.php refuses every action on +// the same two conditions independently — hiding a link is not access control. HOST1 is the +// node with the GPU, the Ollama process and the index; include/ai.php reads only the local +// {HOST}_OLLAMA_URL, so the tab could not function anywhere else regardless. // // DESIGN PRINCIPLES // The banner leads with offload, not with size.