diff --git a/hub.lua b/hub.lua index 27d0475..f4ffcb5 100644 --- a/hub.lua +++ b/hub.lua @@ -29,6 +29,8 @@ local PROBE_TTL_UP = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_UP") or "30") local PROBE_TTL_DOWN_MIN = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MIN") or "60") local PROBE_TTL_DOWN_MAX = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MAX") or "900") local PROBE_BUDGET = tonumber(os.getenv("LMCP_HUB_PROBE_BUDGET") or "3") +-- TCP port that answers "is this host there" for ssh-only backends. +local SSH_PROBE_PORT = os.getenv("LMCP_HUB_SSH_PORT") or "22" local LMCP_TIMEOUT = tonumber(os.getenv("LMCP_HUB_LMCP_TIMEOUT") or "6") local SSH_TIMEOUT = tonumber(os.getenv("LMCP_HUB_SSH_TIMEOUT") or "10") local SSH_HARD_TIMEOUT = tonumber(os.getenv("LMCP_HUB_SSH_HARD_TIMEOUT") or "30") @@ -272,13 +274,19 @@ end -- bash fan-out of curl calls. Total wall clock ≈ PROBE_BUDGET. local function probe_all_parallel(force) local now = os.time() - local need = {} + local need, need_ssh = {}, {} for name, b in pairs(backends) do - if b.lmcp_url and (force or not cache_fresh(status[name], now)) then - need[#need+1] = b + if force or not cache_fresh(status[name], now) then + if b.lmcp_url then + need[#need+1] = b + elseif b.ssh_host then + -- ssh-only: no lmcp endpoint to ask, but "is the box there" is still + -- answerable cheaply. See the SSH probe note below. + need_ssh[#need_ssh+1] = b + end end end - if #need == 0 then return end + if #need == 0 and #need_ssh == 0 then return end local script_parts = {} for _, b in ipairs(need) do @@ -289,6 +297,21 @@ local function probe_all_parallel(force) PROBE_BUDGET, b.name, auth, url, b.name ) end + -- SSH probe. The design note above rejects checking ssh because a session costs + -- 3-6s per offline host — true for a SESSION. A bare TCP connect to 22 answers the + -- only question a host card asks ("is it there") with no handshake and no auth: + -- measured on this host, a dead target costs 1.05s and a live one milliseconds, and + -- it rides the same parallel fan-out, so wall clock stays one budget window. + -- Without nc we report nothing rather than guessing DOWN — a wrong claim is worse + -- than the "no probe result" the dashboard already renders as unknown. + for _, b in ipairs(need_ssh) do + local host = b.ssh_host:gsub("'", "'\\''") + script_parts[#script_parts+1] = string.format( + "(if command -v nc >/dev/null 2>&1; then " .. + "nc -z -w%d '%s' %s >/dev/null 2>&1 && echo '%s SSHUP 0' || echo '%s SSHDOWN 0'; " .. + "else echo '%s SSHSKIP 0'; fi) &", + PROBE_BUDGET, host, SSH_PROBE_PORT, b.name, b.name, b.name) + end script_parts[#script_parts+1] = "wait" local t0 = monotonic() @@ -302,8 +325,14 @@ local function probe_all_parallel(force) local name, code, t = line:match("^(%S+)%s+(%S+)%s+([%d%.]+)") if name then seen[name] = true - local is_up = (code == "200") - if is_up then + if code == "SSHUP" then + apply_probe_result(name, true, nil, "ssh", nil) + elseif code == "SSHDOWN" then + apply_probe_result(name, false, "ssh port unreachable", nil, nil) + elseif code == "SSHSKIP" then + -- nc missing: leave it unprobed rather than assert a state. + seen[name] = nil + elseif code == "200" then apply_probe_result(name, true, nil, "lmcp", nil) else apply_probe_result(name, false, "lmcp code=" .. code, nil, nil) @@ -316,7 +345,7 @@ local function probe_all_parallel(force) apply_probe_result(b.name, false, "probe fan-out missing", nil, nil) end end - logreq("probe_all_parallel n=%d elapsed=%.2fs", #need, dt) + logreq("probe_all_parallel lmcp=%d ssh=%d elapsed=%.2fs", #need, #need_ssh, dt) end -- ---- Call-tool dispatcher ----------------------------------------------