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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ _tasks/
.agents/**
.claude/**
.gemini/**
.code-forge/**
.config/**
.data/**
.logs/**
Expand Down
12 changes: 7 additions & 5 deletions src/app/.well-known/agent-card.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,38 @@
*/

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { getFleetSkills } from "@/lib/conductor/fleetSkills";
import { getBaseUrl } from "@/lib/wellKnown";

const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";

/**
* GET /.well-known/agent-card.json
*
* Returns the OmniRoute Agent Card (A2A v1.0).
*/
export async function GET() {
export async function GET(request: NextRequest) {
const fleetSkills = await getFleetSkills();
const baseUrl = getBaseUrl(request);

const agentCard = {
name: "OmniRoute AI Gateway",
description:
"Intelligent AI routing gateway with 36+ providers, smart fallback, quota tracking, " +
"format translation, and auto-managed combos. Routes AI requests to the optimal " +
"provider based on cost, latency, quota availability, and task requirements.",
url: `${BASE_URL}/a2a`,
url: `${baseUrl}/a2a`,
version: PACKAGE_VERSION,
supportedInterfaces: [
{
url: `${BASE_URL}/a2a`,
url: `${baseUrl}/a2a`,
protocolBinding: "JSONRPC",
protocolVersion: "1.0",
},
{
url: `${BASE_URL}/a2a`,
url: `${baseUrl}/a2a`,
protocolBinding: "JSONRPC",
protocolVersion: "0.3",
},
Expand Down
8 changes: 5 additions & 3 deletions src/app/.well-known/agent.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,31 @@
*/

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { getFleetSkills } from "@/lib/conductor/fleetSkills";
import { getBaseUrl } from "@/lib/wellKnown";

const PACKAGE_VERSION = process.env.npm_package_version || "1.8.1";
const BASE_URL = process.env.OMNIROUTE_BASE_URL || "http://localhost:20128";

/**
* GET /.well-known/agent.json
*
* Returns the OmniRoute Agent Card that describes this gateway's
* capabilities as an A2A agent.
*/
export async function GET() {
export async function GET(request: NextRequest) {
// Conductor PRD RF2: fleet skills from the OmniConductor hub (cached ~60s; [] when
// the hub is unset/offline — the card stays valid without the fleet section).
const fleetSkills = await getFleetSkills();
const baseUrl = getBaseUrl(request);
const agentCard = {
name: "OmniRoute AI 网关",
description:
"智能 AI 路由网关,支持 36+ 个提供者、智能回退、配额跟踪、" +
"格式转换和自动管理组合。根据成本、延迟、配额可用性" +
"和任务要求将 AI 请求路由到最优提供者。",
url: `${BASE_URL}/a2a`,
url: `${baseUrl}/a2a`,
version: PACKAGE_VERSION,
capabilities: {
streaming: true,
Expand Down
17 changes: 9 additions & 8 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
import { classifyIpScope } from "@/lib/ipUtils";
import { getCachedSettings } from "@/lib/db/settings";
Expand All @@ -13,6 +14,7 @@ import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
import { loginSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { checkLoginGuard, clearLoginAttempts, recordLoginFailure } from "@/server/auth/loginGuard";
import { AUTHZ_HEADER_TRUSTED_PEER_IP } from "@/server/authz/headers";

// SECURITY: No hardcoded fallback — JWT_SECRET must be configured.
if (!process.env.JWT_SECRET) {
Expand All @@ -28,7 +30,7 @@ export const authRouteInternals = {
getCookieStore: cookies,
};

export async function POST(request) {
export async function POST(request: NextRequest) {
const auditContext = getAuditRequestContext(request);

try {
Expand Down Expand Up @@ -75,7 +77,10 @@ export async function POST(request) {
return NextResponse.json({ error: "Invalid password payload" }, { status: 400 });
}
const settings = await getCachedSettings();
const clientIp = auditContext.ipAddress || null;
const trustedPeerIp = process.env.OMNIROUTE_PEER_STAMP_TOKEN
? request.headers.get(AUTHZ_HEADER_TRUSTED_PEER_IP)
: null;
const clientIp = trustedPeerIp || auditContext.ipAddress || null;
const oidcDisabledPassword =
settings.oidcEnabled === true &&
(settings.oidcDisablePasswordLogin === true ||
Expand Down Expand Up @@ -118,9 +123,7 @@ export async function POST(request) {
{ error: "Too many failed attempts. Try again later." },
{
status: 429,
headers: guardCheck.retryAfterSeconds
? { "Retry-After": String(guardCheck.retryAfterSeconds) }
: {},
headers: { "Retry-After": String(guardCheck.retryAfterSeconds || 60) },
}
);
}
Expand Down Expand Up @@ -220,9 +223,7 @@ export async function POST(request) {
{ error: "Too many failed attempts. Try again later." },
{
status: 429,
headers: failureDecision.retryAfterSeconds
? { "Retry-After": String(failureDecision.retryAfterSeconds) }
: {},
headers: { "Retry-After": String(failureDecision.retryAfterSeconds || 60) },
}
);
}
Expand Down
11 changes: 11 additions & 0 deletions src/lib/wellKnown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { NextRequest } from "next/server";

/**
* Derive the base URL for A2A agent card endpoints.
* Prefers OMNIROUTE_BASE_URL env var for admin override; falls back to the
* request's dynamic origin so the gateway works behind any hostname without
* hardcoded localhost:20128 (S2 security fix).
*/
export function getBaseUrl(request: NextRequest): string {
return process.env.OMNIROUTE_BASE_URL || request.nextUrl.origin;
}
11 changes: 11 additions & 0 deletions src/server/authz/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";
*/
export const AUTHZ_HEADER_PEER_LOCALITY = "x-omniroute-peer-locality";

/**
* The resolved real peer IP, stamped by the pipeline AFTER verifying the
* token-stamped PEER_IP_HEADER. This is the trusted, non-spoofable IP that
* route handlers (e.g. login rate-limit key) should use instead of re-deriving
* from X-Forwarded-For / X-Real-IP. Set only when the stamp token is configured
* and the HMAC signature validates; absent when the stamp is not in use.
* Stripped from incoming requests like all other trusted headers.
*/
export const AUTHZ_HEADER_TRUSTED_PEER_IP = "x-omniroute-trusted-peer-ip";

/**
* Headers the pipeline must NEVER trust on incoming requests. They are
* stripped before route classification to prevent header-spoofing attacks.
Expand All @@ -73,4 +83,5 @@ export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray<string> = [
AUTHZ_HEADER_AUTH_LABEL,
AUTHZ_HEADER_AUTH_SCOPES,
AUTHZ_HEADER_PEER_LOCALITY,
AUTHZ_HEADER_TRUSTED_PEER_IP,
];
11 changes: 11 additions & 0 deletions src/server/authz/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
AUTHZ_HEADER_PEER_LOCALITY,
AUTHZ_HEADER_REQUEST_ID,
AUTHZ_HEADER_ROUTE_CLASS,
AUTHZ_HEADER_TRUSTED_PEER_IP,
AUTHZ_TRUSTED_HEADERS,
CLI_TOKEN_HEADER,
PEER_IP_HEADER,
Expand Down Expand Up @@ -332,6 +333,16 @@ export async function runAuthzPipeline(
process.env.OMNIROUTE_PEER_STAMP_TOKEN
);
requestHeaders.set(AUTHZ_HEADER_PEER_LOCALITY, peerLocality);
// Stamp the resolved, non-spoofable peer IP for route handlers that need
// the real client IP (e.g. login rate-limit key). Only set when the stamp
// token is configured and the HMAC signature validates; absent otherwise.
const trustedPeerIp = resolveStampedPeer(
request.headers.get(PEER_IP_HEADER),
process.env.OMNIROUTE_PEER_STAMP_TOKEN
);
if (trustedPeerIp) {
requestHeaders.set(AUTHZ_HEADER_TRUSTED_PEER_IP, trustedPeerIp);
}
// Local CLI-token auth is decided centrally above. Preserve that trusted
// decision for route-level requireManagementAuth without forwarding the
// machine token itself: custom client auth headers are stripped before the
Expand Down
Loading
Loading