Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -121,10 +120,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 @@ -291,14 +290,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 @@ -381,7 +398,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 @@ -399,7 +416,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
8 changes: 7 additions & 1 deletion lib/hypatia/web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ defmodule Hypatia.Web.Router do
the bearer-auth gate when HYPATIA_API_BEARER_TOKEN is configured.
"""
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