Skip to content
Open
2 changes: 2 additions & 0 deletions lib/hypatia/diagnostics/monitor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 30 additions & 12 deletions lib/hypatia/web/api_router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = <peer hostname or "unknown">` so the
Expand Down Expand Up @@ -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 <token> 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 <token> 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.
Expand Down Expand Up @@ -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"
)

Expand All @@ -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."
})
)
Expand Down
12 changes: 9 additions & 3 deletions lib/hypatia/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/neural/prover_recommender.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down
64 changes: 64 additions & 0 deletions test/graphql_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }")
Expand Down Expand Up @@ -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
Loading