@@ -884,6 +884,265 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider,
884884/**
885885 * Build the enhanced health response (superset of original format).
886886 */
887+ // ---------------------------------------------------------------------------
888+ // Startup key validation
889+ // ---------------------------------------------------------------------------
890+
891+ /**
892+ * Validation result for a single provider's API key.
893+ * @typedef {'pending'|'valid'|'auth_rejected'|'network_error'|'inconclusive'|'skipped' } ValidationStatus
894+ * @typedef {{ status: ValidationStatus, message: string } } ValidationResult
895+ */
896+
897+ /** @type {Record<string, ValidationResult> } */
898+ const keyValidationResults = { } ;
899+
900+ /** Set to true once validateApiKeys() has finished (regardless of outcome). */
901+ let keyValidationComplete = false ;
902+
903+ /** Reset validation state (used in tests). */
904+ function resetKeyValidationState ( ) {
905+ for ( const key of Object . keys ( keyValidationResults ) ) {
906+ delete keyValidationResults [ key ] ;
907+ }
908+ keyValidationComplete = false ;
909+ }
910+
911+ /**
912+ * Perform a lightweight probe against the provider's API to check if the
913+ * configured key is still accepted. Results are logged and stored in
914+ * `keyValidationResults` — the health endpoint exposes them.
915+ *
916+ * Validation is **non-blocking by default**: the proxy still serves traffic
917+ * even if a key is rejected. Set AWF_VALIDATE_KEYS=strict to exit(1) on
918+ * any auth rejection.
919+ *
920+ * Only validates against known default targets. Custom/enterprise targets
921+ * are skipped because we don't know what probe endpoints they expose.
922+ *
923+ * @param {object } [overrides={}] - Optional key/target overrides (used in tests)
924+ * @param {string } [overrides.openaiKey] - Override OPENAI_API_KEY
925+ * @param {string } [overrides.openaiTarget] - Override OPENAI_API_TARGET
926+ * @param {string } [overrides.anthropicKey] - Override ANTHROPIC_API_KEY
927+ * @param {string } [overrides.anthropicTarget] - Override ANTHROPIC_API_TARGET
928+ * @param {string } [overrides.copilotGithubToken] - Override COPILOT_GITHUB_TOKEN
929+ * @param {string } [overrides.copilotApiKey] - Override COPILOT_API_KEY
930+ * @param {string } [overrides.copilotAuthToken] - Override COPILOT_AUTH_TOKEN
931+ * @param {string } [overrides.copilotTarget] - Override COPILOT_API_TARGET
932+ * @param {string } [overrides.copilotIntegrationId] - Override COPILOT_INTEGRATION_ID
933+ * @param {string } [overrides.geminiKey] - Override GEMINI_API_KEY
934+ * @param {string } [overrides.geminiTarget] - Override GEMINI_API_TARGET
935+ * @param {number } [overrides.timeoutMs] - Override probe timeout
936+ */
937+ async function validateApiKeys ( overrides = { } ) {
938+ const mode = ( process . env . AWF_VALIDATE_KEYS || 'warn' ) . toLowerCase ( ) ; // off | warn | strict
939+ if ( mode === 'off' ) {
940+ logRequest ( 'info' , 'key_validation' , { message : 'Key validation disabled (AWF_VALIDATE_KEYS=off)' } ) ;
941+ keyValidationComplete = true ;
942+ return ;
943+ }
944+
945+ const ov = ( key , fallback ) => key in overrides ? overrides [ key ] : fallback ;
946+ const openaiKey = ov ( 'openaiKey' , OPENAI_API_KEY ) ;
947+ const openaiTarget = ov ( 'openaiTarget' , OPENAI_API_TARGET ) ;
948+ const anthropicKey = ov ( 'anthropicKey' , ANTHROPIC_API_KEY ) ;
949+ const anthropicTarget = ov ( 'anthropicTarget' , ANTHROPIC_API_TARGET ) ;
950+ const copilotGithubToken = ov ( 'copilotGithubToken' , COPILOT_GITHUB_TOKEN ) ;
951+ const copilotApiKey = ov ( 'copilotApiKey' , COPILOT_API_KEY ) ;
952+ const copilotAuthToken = ov ( 'copilotAuthToken' , COPILOT_AUTH_TOKEN ) ;
953+ const copilotTarget = ov ( 'copilotTarget' , COPILOT_API_TARGET ) ;
954+ const copilotIntegrationId = ov ( 'copilotIntegrationId' , COPILOT_INTEGRATION_ID ) ;
955+ const geminiKey = ov ( 'geminiKey' , GEMINI_API_KEY ) ;
956+ const geminiTarget = ov ( 'geminiTarget' , GEMINI_API_TARGET ) ;
957+ const TIMEOUT_MS = ov ( 'timeoutMs' , 10_000 ) ;
958+
959+ const probes = [ ] ;
960+
961+ // --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) ---
962+ if ( copilotGithubToken ) {
963+ if ( copilotTarget !== 'api.githubcopilot.com' ) {
964+ keyValidationResults . copilot = { status : 'skipped' , message : `Custom target ${ copilotTarget } ; validation skipped` } ;
965+ logRequest ( 'info' , 'key_validation' , { provider : 'copilot' , ...keyValidationResults . copilot } ) ;
966+ } else {
967+ probes . push ( probeProvider ( 'copilot' , `https://${ copilotTarget } /models` , {
968+ method : 'GET' ,
969+ headers : {
970+ 'Authorization' : `Bearer ${ copilotGithubToken } ` ,
971+ 'Copilot-Integration-Id' : copilotIntegrationId ,
972+ } ,
973+ } , TIMEOUT_MS ) ) ;
974+ }
975+ } else if ( copilotApiKey && ! copilotGithubToken ) {
976+ keyValidationResults . copilot = { status : 'skipped' , message : 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' } ;
977+ logRequest ( 'info' , 'key_validation' , { provider : 'copilot' , ...keyValidationResults . copilot } ) ;
978+ }
979+
980+ // --- OpenAI ---
981+ if ( openaiKey ) {
982+ if ( openaiTarget !== 'api.openai.com' ) {
983+ keyValidationResults . openai = { status : 'skipped' , message : `Custom target ${ openaiTarget } ; validation skipped` } ;
984+ logRequest ( 'info' , 'key_validation' , { provider : 'openai' , ...keyValidationResults . openai } ) ;
985+ } else {
986+ probes . push ( probeProvider ( 'openai' , `https://${ openaiTarget } /v1/models` , {
987+ method : 'GET' ,
988+ headers : { 'Authorization' : `Bearer ${ openaiKey } ` } ,
989+ } , TIMEOUT_MS ) ) ;
990+ }
991+ }
992+
993+ // --- Anthropic ---
994+ if ( anthropicKey ) {
995+ if ( anthropicTarget !== 'api.anthropic.com' ) {
996+ keyValidationResults . anthropic = { status : 'skipped' , message : `Custom target ${ anthropicTarget } ; validation skipped` } ;
997+ logRequest ( 'info' , 'key_validation' , { provider : 'anthropic' , ...keyValidationResults . anthropic } ) ;
998+ } else {
999+ // POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid
1000+ probes . push ( probeProvider ( 'anthropic' , `https://${ anthropicTarget } /v1/messages` , {
1001+ method : 'POST' ,
1002+ headers : {
1003+ 'x-api-key' : anthropicKey ,
1004+ 'anthropic-version' : '2023-06-01' ,
1005+ 'content-type' : 'application/json' ,
1006+ } ,
1007+ body : '{}' ,
1008+ } , TIMEOUT_MS ) ) ;
1009+ }
1010+ }
1011+
1012+ // --- Gemini ---
1013+ if ( geminiKey ) {
1014+ if ( geminiTarget !== 'generativelanguage.googleapis.com' ) {
1015+ keyValidationResults . gemini = { status : 'skipped' , message : `Custom target ${ geminiTarget } ; validation skipped` } ;
1016+ logRequest ( 'info' , 'key_validation' , { provider : 'gemini' , ...keyValidationResults . gemini } ) ;
1017+ } else {
1018+ probes . push ( probeProvider ( 'gemini' , `https://${ geminiTarget } /v1beta/models` , {
1019+ method : 'GET' ,
1020+ headers : { 'x-goog-api-key' : geminiKey } ,
1021+ } , TIMEOUT_MS ) ) ;
1022+ }
1023+ }
1024+
1025+ if ( probes . length === 0 ) {
1026+ logRequest ( 'info' , 'key_validation' , { message : 'No providers to validate' } ) ;
1027+ keyValidationComplete = true ;
1028+ return ;
1029+ }
1030+
1031+ await Promise . allSettled ( probes ) ;
1032+ keyValidationComplete = true ;
1033+
1034+ // Summarize
1035+ const failures = Object . entries ( keyValidationResults )
1036+ . filter ( ( [ , r ] ) => r . status === 'auth_rejected' ) ;
1037+
1038+ if ( failures . length > 0 ) {
1039+ for ( const [ provider , result ] of failures ) {
1040+ logRequest ( 'error' , 'key_validation_failed' , {
1041+ provider,
1042+ message : `${ provider . toUpperCase ( ) } API key validation failed — ${ result . message } . Rotate the secret and re-run.` ,
1043+ } ) ;
1044+ }
1045+ if ( mode === 'strict' ) {
1046+ logRequest ( 'error' , 'key_validation_strict_exit' , {
1047+ message : `AWF_VALIDATE_KEYS=strict: exiting due to ${ failures . length } auth failure(s)` ,
1048+ providers : failures . map ( ( [ p ] ) => p ) ,
1049+ } ) ;
1050+ process . exit ( 1 ) ;
1051+ }
1052+ } else {
1053+ logRequest ( 'info' , 'key_validation' , { message : 'All configured API keys validated successfully' } ) ;
1054+ }
1055+ }
1056+
1057+ /**
1058+ * Probe a single provider to check if the API key is accepted.
1059+ *
1060+ * @param {string } provider - Provider name (copilot, openai, etc.)
1061+ * @param {string } url - Probe URL
1062+ * @param {{ method: string, headers: Record<string,string>, body?: string } } opts
1063+ * @param {number } timeoutMs
1064+ */
1065+ async function probeProvider ( provider , url , opts , timeoutMs ) {
1066+ keyValidationResults [ provider ] = { status : 'pending' , message : 'Validating...' } ;
1067+ try {
1068+ const status = await httpProbe ( url , opts , timeoutMs ) ;
1069+
1070+ if ( status >= 200 && status < 300 ) {
1071+ keyValidationResults [ provider ] = { status : 'valid' , message : `HTTP ${ status } ` } ;
1072+ logRequest ( 'info' , 'key_validation' , { provider, status : 'valid' , httpStatus : status } ) ;
1073+ } else if ( status === 401 || status === 403 ) {
1074+ keyValidationResults [ provider ] = { status : 'auth_rejected' , message : `HTTP ${ status } — token expired or invalid` } ;
1075+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'auth_rejected' , httpStatus : status } ) ;
1076+ } else if ( status === 400 ) {
1077+ // 400 for Anthropic means key is valid but request body was bad — expected
1078+ keyValidationResults [ provider ] = { status : 'valid' , message : `HTTP ${ status } (auth accepted, probe body rejected)` } ;
1079+ logRequest ( 'info' , 'key_validation' , { provider, status : 'valid' , httpStatus : status , note : 'probe body rejected but auth accepted' } ) ;
1080+ } else {
1081+ keyValidationResults [ provider ] = { status : 'inconclusive' , message : `HTTP ${ status } ` } ;
1082+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'inconclusive' , httpStatus : status } ) ;
1083+ }
1084+ } catch ( err ) {
1085+ const message = err && err . message ? err . message : String ( err ) ;
1086+ keyValidationResults [ provider ] = { status : 'network_error' , message } ;
1087+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'network_error' , error : message } ) ;
1088+ }
1089+ }
1090+
1091+ /**
1092+ * Make an HTTPS request through the proxy and return the HTTP status code.
1093+ *
1094+ * @param {string } url
1095+ * @param {{ method: string, headers: Record<string,string>, body?: string } } opts
1096+ * @param {number } timeoutMs
1097+ * @returns {Promise<number> } HTTP status code
1098+ */
1099+ function httpProbe ( url , opts , timeoutMs ) {
1100+ return new Promise ( ( resolve , reject ) => {
1101+ const parsed = new URL ( url ) ;
1102+ const isHttps = parsed . protocol === 'https:' ;
1103+ const mod = isHttps ? https : http ;
1104+ const reqOpts = {
1105+ hostname : parsed . hostname ,
1106+ port : parsed . port || ( isHttps ? 443 : 80 ) ,
1107+ path : parsed . pathname + parsed . search ,
1108+ method : opts . method ,
1109+ headers : { ...opts . headers } ,
1110+ ...( isHttps && proxyAgent ? { agent : proxyAgent } : { } ) ,
1111+ timeout : timeoutMs ,
1112+ } ;
1113+
1114+ let settled = false ;
1115+ const resolveOnce = ( statusCode ) => {
1116+ if ( settled ) return ;
1117+ settled = true ;
1118+ resolve ( statusCode ) ;
1119+ } ;
1120+ const rejectOnce = ( err ) => {
1121+ if ( settled ) return ;
1122+ settled = true ;
1123+ reject ( err ) ;
1124+ } ;
1125+
1126+ const req = mod . request ( reqOpts , ( res ) => {
1127+ // Consume body to free the socket
1128+ res . resume ( ) ;
1129+ res . on ( 'end' , ( ) => resolveOnce ( res . statusCode ) ) ;
1130+ res . on ( 'error' , rejectOnce ) ;
1131+ res . on ( 'close' , ( ) => resolveOnce ( res . statusCode ) ) ;
1132+ } ) ;
1133+
1134+ req . on ( 'timeout' , ( ) => {
1135+ req . destroy ( new Error ( `Probe timed out after ${ timeoutMs } ms` ) ) ;
1136+ } ) ;
1137+ req . on ( 'error' , rejectOnce ) ;
1138+
1139+ if ( opts . body ) {
1140+ req . write ( opts . body ) ;
1141+ }
1142+ req . end ( ) ;
1143+ } ) ;
1144+ }
1145+
8871146function healthResponse ( ) {
8881147 return {
8891148 status : 'healthy' ,
@@ -895,6 +1154,10 @@ function healthResponse() {
8951154 gemini : ! ! GEMINI_API_KEY ,
8961155 copilot : ! ! COPILOT_AUTH_TOKEN ,
8971156 } ,
1157+ key_validation : {
1158+ complete : keyValidationComplete ,
1159+ results : keyValidationResults ,
1160+ } ,
8981161 metrics_summary : metrics . getSummary ( ) ,
8991162 rate_limits : limiter . getAllStatus ( ) ,
9001163 } ;
@@ -923,6 +1186,26 @@ if (require.main === module) {
9231186 // Health port is always 10000 — this is what Docker healthcheck hits
9241187 const HEALTH_PORT = 10000 ;
9251188
1189+ // Startup latch: count listeners that participate in key validation.
1190+ // The no-key Gemini 503 handler binds port 10003 but doesn't participate
1191+ // in validation, so it's intentionally excluded from the count.
1192+ let expectedListeners = 1 ; // port 10000 (always)
1193+ if ( ANTHROPIC_API_KEY ) expectedListeners ++ ;
1194+ if ( COPILOT_AUTH_TOKEN ) expectedListeners ++ ;
1195+ if ( GEMINI_API_KEY ) expectedListeners ++ ;
1196+ if ( OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN ) expectedListeners ++ ; // OpenCode (10004)
1197+ let readyListeners = 0 ;
1198+ function onListenerReady ( ) {
1199+ readyListeners ++ ;
1200+ if ( readyListeners === expectedListeners ) {
1201+ logRequest ( 'info' , 'startup_complete' , { message : `All ${ expectedListeners } validation-participating listeners ready, starting key validation` } ) ;
1202+ validateApiKeys ( ) . catch ( ( err ) => {
1203+ logRequest ( 'error' , 'key_validation_error' , { message : 'Unexpected error during key validation' , error : String ( err ) } ) ;
1204+ keyValidationComplete = true ;
1205+ } ) ;
1206+ }
1207+ }
1208+
9261209 // OpenAI API proxy (port 10000)
9271210 if ( OPENAI_API_KEY ) {
9281211 const server = http . createServer ( ( req , res ) => {
@@ -943,6 +1226,7 @@ if (require.main === module) {
9431226
9441227 server . listen ( HEALTH_PORT , '0.0.0.0' , ( ) => {
9451228 logRequest ( 'info' , 'server_start' , { message : `OpenAI proxy listening on port ${ HEALTH_PORT } ` , target : OPENAI_API_TARGET } ) ;
1229+ onListenerReady ( ) ;
9461230 } ) ;
9471231 } else {
9481232 // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck
@@ -960,6 +1244,7 @@ if (require.main === module) {
9601244
9611245 server . listen ( HEALTH_PORT , '0.0.0.0' , ( ) => {
9621246 logRequest ( 'info' , 'server_start' , { message : `Health endpoint listening on port ${ HEALTH_PORT } (OpenAI not configured)` } ) ;
1247+ onListenerReady ( ) ;
9631248 } ) ;
9641249 }
9651250
@@ -993,6 +1278,7 @@ if (require.main === module) {
9931278
9941279 server . listen ( 10001 , '0.0.0.0' , ( ) => {
9951280 logRequest ( 'info' , 'server_start' , { message : 'Anthropic proxy listening on port 10001' , target : ANTHROPIC_API_TARGET } ) ;
1281+ onListenerReady ( ) ;
9961282 } ) ;
9971283 }
9981284
@@ -1053,6 +1339,7 @@ if (require.main === module) {
10531339
10541340 copilotServer . listen ( 10002 , '0.0.0.0' , ( ) => {
10551341 logRequest ( 'info' , 'server_start' , { message : 'GitHub Copilot proxy listening on port 10002' } ) ;
1342+ onListenerReady ( ) ;
10561343 } ) ;
10571344 }
10581345
@@ -1087,6 +1374,7 @@ if (require.main === module) {
10871374
10881375 geminiServer . listen ( 10003 , '0.0.0.0' , ( ) => {
10891376 logRequest ( 'info' , 'server_start' , { message : 'Google Gemini proxy listening on port 10003' , target : GEMINI_API_TARGET } ) ;
1377+ onListenerReady ( ) ;
10901378 } ) ;
10911379 } else {
10921380 // No Gemini key — listen on port 10003 and return 503 so the Gemini CLI
@@ -1195,6 +1483,7 @@ if (require.main === module) {
11951483
11961484 opencodeServer . listen ( 10004 , '0.0.0.0' , ( ) => {
11971485 logRequest ( 'info' , 'server_start' , { message : `OpenCode proxy listening on port 10004 (-> ${ opencodeStartupRoute . target } )` } ) ;
1486+ onListenerReady ( ) ;
11981487 } ) ;
11991488 }
12001489
@@ -1213,4 +1502,4 @@ if (require.main === module) {
12131502}
12141503
12151504// Export for testing
1216- module . exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam } ;
1505+ module . exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys , probeProvider , httpProbe , keyValidationResults , resetKeyValidationState } ;
0 commit comments