diff --git a/lib/hypatia/diagnostics/monitor.ex b/lib/hypatia/diagnostics/monitor.ex index f602ab53..f759f2e9 100644 --- a/lib/hypatia/diagnostics/monitor.ex +++ b/lib/hypatia/diagnostics/monitor.ex @@ -152,6 +152,8 @@ defmodule Hypatia.Diagnostics.Monitor do end end + # Probe the neural coordinator without treating a long-running training + # cycle as a crash. Other exits and exceptions report a failed health check. defp check_neural() do try do case GenServer.call(Hypatia.Neural.Coordinator, :status, 1000) do diff --git a/lib/hypatia/web/api_router.ex b/lib/hypatia/web/api_router.ex index 1f67528e..4d8cb2ee 100644 --- a/lib/hypatia/web/api_router.ex +++ b/lib/hypatia/web/api_router.ex @@ -21,8 +21,7 @@ defmodule Hypatia.Web.ApiRouter do import Bitwise, only: [|||: 2, bxor: 2] plug(:match) - plug(:auth_gate) - plug(:loopback_only) + plug(:protect) plug(:dispatch) get "/status" do @@ -115,10 +114,10 @@ defmodule Hypatia.Web.ApiRouter do # POST /api/alerts/ingest -- Federation ingress. Peer hypatia # instances POST their alerts here via the Peer sink. # - # Auth: the auth_gate plug enforces a valid bearer token, so this - # endpoint is only reachable when HYPATIA_API_BEARER_TOKEN is set - # and the request carries it. Federation without shared auth is - # refused at the gate, not here. + # Auth: when HYPATIA_API_BEARER_TOKEN is set, auth_gate requires a + # valid bearer token. When it is unset or empty, access follows the + # loopback_only policy, including the HYPATIA_API_ALLOW_NONLOCAL + # override. # # Loop prevention: the ingested alert is tagged with # `metadata.federated_from = ` so the @@ -285,14 +284,32 @@ defmodule Hypatia.Web.ApiRouter do json(conn, 404, %{error: "not_found"}) end + @doc """ + Applies the operational API's bearer-token and loopback access controls. + + A successfully authenticated bearer token bypasses the loopback check. When + no non-empty token is configured, the request remains subject to the + loopback policy and its explicit non-local override. + """ + def protect(conn, _opts) do + conn = auth_gate(conn, []) + + if conn.halted do + conn + else + loopback_only(conn, []) + end + end + # ─── Plug ────────────────────────────────────────────────────────────── # ─── Auth gate ───────────────────────────────────────────────────────── # - # If HYPATIA_API_BEARER_TOKEN is set, any /api/* request must carry a - # matching Authorization: Bearer header. The token + loopback - # checks compose: with neither, /api is loopback-only. With both, /api - # is openable to non-local callers provided they present the token. + # If HYPATIA_API_BEARER_TOKEN is non-empty, a protected request must + # carry a matching Authorization: Bearer header. If it is unset + # or empty, the request reaches loopback_only/2, where loopback clients + # are allowed and HYPATIA_API_ALLOW_NONLOCAL=true also permits non-local + # requests without a bearer token. A valid bearer bypasses the IP check. # # Token comparison uses Plug.Crypto.secure_compare/2 so timing attacks # can't enumerate the secret. @@ -375,7 +392,7 @@ defmodule Hypatia.Web.ApiRouter do cond do System.get_env("HYPATIA_API_ALLOW_NONLOCAL") == "true" -> Logger.warning( - "Hypatia /api access from #{inspect(conn.remote_ip)} allowed by " <> + "Hypatia operational API access from #{inspect(conn.remote_ip)} allowed by " <> "HYPATIA_API_ALLOW_NONLOCAL env override" ) @@ -393,7 +410,8 @@ defmodule Hypatia.Web.ApiRouter do error: "loopback_only", path: conn.request_path, hint: - "Hypatia /api is loopback-only. Set HYPATIA_API_ALLOW_NONLOCAL=true to " <> + "Hypatia operational endpoints are loopback-only. " <> + "Set HYPATIA_API_ALLOW_NONLOCAL=true to " <> "permit non-local clients, or tunnel via SSH." }) ) diff --git a/lib/hypatia/web/router.ex b/lib/hypatia/web/router.ex index e2098bc9..b3f211a1 100644 --- a/lib/hypatia/web/router.ex +++ b/lib/hypatia/web/router.ex @@ -84,10 +84,16 @@ defmodule Hypatia.Web.Router do # # Minimal hand-rolled implementation; no introspection, no schema # federation, no Absinthe dep. See lib/hypatia/web/graphql.ex for - # the supported field set and limitations. Loopback-only by sharing - # the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured. + # the supported field set and limitations. Uses the same optional + # bearer-auth and loopback gates as the operational API. post "/graphql" do - Hypatia.Web.GraphQL.call(conn, []) + conn = Hypatia.Web.ApiRouter.protect(conn, []) + + if conn.halted do + conn + else + Hypatia.Web.GraphQL.call(conn, []) + end end match _ do diff --git a/lib/neural/prover_recommender.ex b/lib/neural/prover_recommender.ex index b41b9e70..6ca0bde1 100644 --- a/lib/neural/prover_recommender.ex +++ b/lib/neural/prover_recommender.ex @@ -120,6 +120,8 @@ defmodule Hypatia.Neural.ProverRecommender do # --- verisim-api bridge --------------------------------------------------- + # Fetch recent proof attempts from the row-level VeriSim API, falling back + # to aggregate strategy data when that endpoint is unavailable. defp fetch_attempts(limit, base_url) do resolved_url = base_url || @verisim_base_url url = "#{resolved_url}/api/v1/proof_attempts?limit=#{limit}" @@ -131,6 +133,8 @@ defmodule Hypatia.Neural.ProverRecommender do end end + # Convert aggregate ClickHouse-backed strategy recommendations into the + # synthetic attempt rows expected by the recommender's training pipeline. defp fetch_attempts_via_clickhouse(limit, base_url) do resolved_url = base_url || @verisim_base_url # ClickHouse HTTP: reach it by probing each active class's strategy endpoint diff --git a/test/graphql_test.exs b/test/graphql_test.exs index e752a8d0..98074334 100644 --- a/test/graphql_test.exs +++ b/test/graphql_test.exs @@ -4,9 +4,20 @@ defmodule Hypatia.Web.GraphQLTest do use ExUnit.Case, async: false + import Plug.Test + alias Hypatia.Web.GraphQL + alias Hypatia.Web.Router setup do + System.delete_env("HYPATIA_API_ALLOW_NONLOCAL") + System.delete_env("HYPATIA_API_BEARER_TOKEN") + + on_exit(fn -> + System.delete_env("HYPATIA_API_ALLOW_NONLOCAL") + System.delete_env("HYPATIA_API_BEARER_TOKEN") + end) + case Process.whereis(Hypatia.Watcher.PubSub) do nil -> {:ok, pid} = Registry.start_link(keys: :duplicate, name: Hypatia.Watcher.PubSub) @@ -28,6 +39,46 @@ defmodule Hypatia.Web.GraphQLTest do :ok end + describe "POST /graphql protection" do + test "requires the configured bearer token" do + System.put_env("HYPATIA_API_BEARER_TOKEN", "test-secret-abc123") + + conn = call_router({127, 0, 0, 1}) + + assert conn.status == 401 + assert Jason.decode!(conn.resp_body)["error"] == "missing_token" + end + + test "accepts a valid bearer token from a non-loopback client" do + System.put_env("HYPATIA_API_BEARER_TOKEN", "test-secret-abc123") + + conn = + {10, 1, 2, 3} + |> graphql_conn() + |> Plug.Conn.put_req_header("authorization", "Bearer test-secret-abc123") + |> Router.call(Router.init([])) + + assert conn.status == 200 + assert Jason.decode!(conn.resp_body)["data"]["health"]["status"] == "ok" + end + + test "rejects a non-loopback client when no token is configured" do + conn = call_router({10, 1, 2, 3}) + + assert conn.status == 403 + assert Jason.decode!(conn.resp_body)["error"] == "loopback_only" + end + + test "allows a non-loopback client through the explicit override" do + System.put_env("HYPATIA_API_ALLOW_NONLOCAL", "true") + + conn = call_router({10, 1, 2, 3}) + + assert conn.status == 200 + assert Jason.decode!(conn.resp_body)["data"]["health"]["status"] == "ok" + end + end + describe "execute/1 — single field" do test "{ health } returns the health payload" do result = GraphQL.execute("{ health }") @@ -102,4 +153,17 @@ defmodule Hypatia.Web.GraphQLTest do assert is_map(result["data"]["status"]) end end + + defp call_router(remote_ip) do + remote_ip + |> graphql_conn() + |> Router.call(Router.init([])) + end + + defp graphql_conn(remote_ip) do + :post + |> conn("/graphql", Jason.encode!(%{query: "{ health }"})) + |> Map.put(:remote_ip, remote_ip) + |> Plug.Conn.put_req_header("content-type", "application/json") + end end