@@ -884,6 +884,216 @@ 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+ /**
904+ * Perform a lightweight probe against the provider's API to check if the
905+ * configured key is still accepted. Results are logged and stored in
906+ * `keyValidationResults` — the health endpoint exposes them.
907+ *
908+ * Validation is **non-blocking by default**: the proxy still serves traffic
909+ * even if a key is rejected. Set AWF_VALIDATE_KEYS=strict to exit(1) on
910+ * any auth rejection.
911+ *
912+ * Only validates against known default targets. Custom/enterprise targets
913+ * are skipped because we don't know what probe endpoints they expose.
914+ */
915+ async function validateApiKeys ( ) {
916+ const mode = ( process . env . AWF_VALIDATE_KEYS || 'warn' ) . toLowerCase ( ) ; // off | warn | strict
917+ if ( mode === 'off' ) {
918+ logRequest ( 'info' , 'key_validation' , { message : 'Key validation disabled (AWF_VALIDATE_KEYS=off)' } ) ;
919+ keyValidationComplete = true ;
920+ return ;
921+ }
922+
923+ const TIMEOUT_MS = 10_000 ;
924+ const probes = [ ] ;
925+
926+ // --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) ---
927+ if ( COPILOT_GITHUB_TOKEN ) {
928+ if ( COPILOT_API_TARGET !== 'api.githubcopilot.com' ) {
929+ keyValidationResults . copilot = { status : 'skipped' , message : `Custom target ${ COPILOT_API_TARGET } ; validation skipped` } ;
930+ logRequest ( 'info' , 'key_validation' , { provider : 'copilot' , ...keyValidationResults . copilot } ) ;
931+ } else {
932+ probes . push ( probeProvider ( 'copilot' , `https://${ COPILOT_API_TARGET } /models` , {
933+ method : 'GET' ,
934+ headers : {
935+ 'Authorization' : `Bearer ${ COPILOT_GITHUB_TOKEN } ` ,
936+ 'Copilot-Integration-Id' : COPILOT_INTEGRATION_ID ,
937+ } ,
938+ } , TIMEOUT_MS ) ) ;
939+ }
940+ } else if ( COPILOT_API_KEY && ! COPILOT_GITHUB_TOKEN ) {
941+ keyValidationResults . copilot = { status : 'skipped' , message : 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' } ;
942+ logRequest ( 'info' , 'key_validation' , { provider : 'copilot' , ...keyValidationResults . copilot } ) ;
943+ }
944+
945+ // --- OpenAI ---
946+ if ( OPENAI_API_KEY ) {
947+ if ( OPENAI_API_TARGET !== 'api.openai.com' ) {
948+ keyValidationResults . openai = { status : 'skipped' , message : `Custom target ${ OPENAI_API_TARGET } ; validation skipped` } ;
949+ logRequest ( 'info' , 'key_validation' , { provider : 'openai' , ...keyValidationResults . openai } ) ;
950+ } else {
951+ probes . push ( probeProvider ( 'openai' , `https://${ OPENAI_API_TARGET } /v1/models` , {
952+ method : 'GET' ,
953+ headers : { 'Authorization' : `Bearer ${ OPENAI_API_KEY } ` } ,
954+ } , TIMEOUT_MS ) ) ;
955+ }
956+ }
957+
958+ // --- Anthropic ---
959+ if ( ANTHROPIC_API_KEY ) {
960+ if ( ANTHROPIC_API_TARGET !== 'api.anthropic.com' ) {
961+ keyValidationResults . anthropic = { status : 'skipped' , message : `Custom target ${ ANTHROPIC_API_TARGET } ; validation skipped` } ;
962+ logRequest ( 'info' , 'key_validation' , { provider : 'anthropic' , ...keyValidationResults . anthropic } ) ;
963+ } else {
964+ // POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid
965+ probes . push ( probeProvider ( 'anthropic' , `https://${ ANTHROPIC_API_TARGET } /v1/messages` , {
966+ method : 'POST' ,
967+ headers : {
968+ 'x-api-key' : ANTHROPIC_API_KEY ,
969+ 'anthropic-version' : '2023-06-01' ,
970+ 'content-type' : 'application/json' ,
971+ } ,
972+ body : '{}' ,
973+ } , TIMEOUT_MS ) ) ;
974+ }
975+ }
976+
977+ // --- Gemini ---
978+ if ( GEMINI_API_KEY ) {
979+ if ( GEMINI_API_TARGET !== 'generativelanguage.googleapis.com' ) {
980+ keyValidationResults . gemini = { status : 'skipped' , message : `Custom target ${ GEMINI_API_TARGET } ; validation skipped` } ;
981+ logRequest ( 'info' , 'key_validation' , { provider : 'gemini' , ...keyValidationResults . gemini } ) ;
982+ } else {
983+ probes . push ( probeProvider ( 'gemini' , `https://${ GEMINI_API_TARGET } /v1beta/models` , {
984+ method : 'GET' ,
985+ headers : { 'x-goog-api-key' : GEMINI_API_KEY } ,
986+ } , TIMEOUT_MS ) ) ;
987+ }
988+ }
989+
990+ if ( probes . length === 0 ) {
991+ logRequest ( 'info' , 'key_validation' , { message : 'No providers to validate' } ) ;
992+ keyValidationComplete = true ;
993+ return ;
994+ }
995+
996+ await Promise . allSettled ( probes ) ;
997+ keyValidationComplete = true ;
998+
999+ // Summarize
1000+ const failures = Object . entries ( keyValidationResults )
1001+ . filter ( ( [ , r ] ) => r . status === 'auth_rejected' ) ;
1002+
1003+ if ( failures . length > 0 ) {
1004+ for ( const [ provider , result ] of failures ) {
1005+ logRequest ( 'error' , 'key_validation_failed' , {
1006+ provider,
1007+ message : `${ provider . toUpperCase ( ) } API key validation failed — ${ result . message } . Rotate the secret and re-run.` ,
1008+ } ) ;
1009+ }
1010+ if ( mode === 'strict' ) {
1011+ logRequest ( 'error' , 'key_validation_strict_exit' , {
1012+ message : `AWF_VALIDATE_KEYS=strict: exiting due to ${ failures . length } auth failure(s)` ,
1013+ providers : failures . map ( ( [ p ] ) => p ) ,
1014+ } ) ;
1015+ process . exit ( 1 ) ;
1016+ }
1017+ } else {
1018+ logRequest ( 'info' , 'key_validation' , { message : 'All configured API keys validated successfully' } ) ;
1019+ }
1020+ }
1021+
1022+ /**
1023+ * Probe a single provider to check if the API key is accepted.
1024+ *
1025+ * @param {string } provider - Provider name (copilot, openai, etc.)
1026+ * @param {string } url - Probe URL
1027+ * @param {{ method: string, headers: Record<string,string>, body?: string } } opts
1028+ * @param {number } timeoutMs
1029+ */
1030+ async function probeProvider ( provider , url , opts , timeoutMs ) {
1031+ keyValidationResults [ provider ] = { status : 'pending' , message : 'Validating...' } ;
1032+ try {
1033+ const status = await httpProbe ( url , opts , timeoutMs ) ;
1034+
1035+ if ( status >= 200 && status < 300 ) {
1036+ keyValidationResults [ provider ] = { status : 'valid' , message : `HTTP ${ status } ` } ;
1037+ logRequest ( 'info' , 'key_validation' , { provider, status : 'valid' , httpStatus : status } ) ;
1038+ } else if ( status === 401 || status === 403 ) {
1039+ keyValidationResults [ provider ] = { status : 'auth_rejected' , message : `HTTP ${ status } — token expired or invalid` } ;
1040+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'auth_rejected' , httpStatus : status } ) ;
1041+ } else if ( status === 400 ) {
1042+ // 400 for Anthropic means key is valid but request body was bad — expected
1043+ keyValidationResults [ provider ] = { status : 'valid' , message : `HTTP ${ status } (auth accepted, probe body rejected)` } ;
1044+ logRequest ( 'info' , 'key_validation' , { provider, status : 'valid' , httpStatus : status , note : 'probe body rejected but auth accepted' } ) ;
1045+ } else {
1046+ keyValidationResults [ provider ] = { status : 'inconclusive' , message : `HTTP ${ status } ` } ;
1047+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'inconclusive' , httpStatus : status } ) ;
1048+ }
1049+ } catch ( err ) {
1050+ const message = err && err . message ? err . message : String ( err ) ;
1051+ keyValidationResults [ provider ] = { status : 'network_error' , message } ;
1052+ logRequest ( 'warn' , 'key_validation' , { provider, status : 'network_error' , error : message } ) ;
1053+ }
1054+ }
1055+
1056+ /**
1057+ * Make an HTTPS request through the proxy and return the HTTP status code.
1058+ *
1059+ * @param {string } url
1060+ * @param {{ method: string, headers: Record<string,string>, body?: string } } opts
1061+ * @param {number } timeoutMs
1062+ * @returns {Promise<number> } HTTP status code
1063+ */
1064+ function httpProbe ( url , opts , timeoutMs ) {
1065+ return new Promise ( ( resolve , reject ) => {
1066+ const parsed = new URL ( url ) ;
1067+ const isHttps = parsed . protocol === 'https:' ;
1068+ const mod = isHttps ? https : http ;
1069+ const reqOpts = {
1070+ hostname : parsed . hostname ,
1071+ port : parsed . port || ( isHttps ? 443 : 80 ) ,
1072+ path : parsed . pathname + parsed . search ,
1073+ method : opts . method ,
1074+ headers : { ...opts . headers } ,
1075+ ...( isHttps && proxyAgent ? { agent : proxyAgent } : { } ) ,
1076+ timeout : timeoutMs ,
1077+ } ;
1078+
1079+ const req = mod . request ( reqOpts , ( res ) => {
1080+ // Consume body to free the socket
1081+ res . resume ( ) ;
1082+ res . on ( 'end' , ( ) => resolve ( res . statusCode ) ) ;
1083+ } ) ;
1084+
1085+ req . on ( 'timeout' , ( ) => {
1086+ req . destroy ( new Error ( `Probe timed out after ${ timeoutMs } ms` ) ) ;
1087+ } ) ;
1088+ req . on ( 'error' , reject ) ;
1089+
1090+ if ( opts . body ) {
1091+ req . write ( opts . body ) ;
1092+ }
1093+ req . end ( ) ;
1094+ } ) ;
1095+ }
1096+
8871097function healthResponse ( ) {
8881098 return {
8891099 status : 'healthy' ,
@@ -895,6 +1105,10 @@ function healthResponse() {
8951105 gemini : ! ! GEMINI_API_KEY ,
8961106 copilot : ! ! COPILOT_AUTH_TOKEN ,
8971107 } ,
1108+ key_validation : {
1109+ complete : keyValidationComplete ,
1110+ results : keyValidationResults ,
1111+ } ,
8981112 metrics_summary : metrics . getSummary ( ) ,
8991113 rate_limits : limiter . getAllStatus ( ) ,
9001114 } ;
@@ -923,6 +1137,24 @@ if (require.main === module) {
9231137 // Health port is always 10000 — this is what Docker healthcheck hits
9241138 const HEALTH_PORT = 10000 ;
9251139
1140+ // Startup latch: count expected listeners, run validation when all are ready
1141+ let expectedListeners = 1 ; // port 10000 (always)
1142+ if ( ANTHROPIC_API_KEY ) expectedListeners ++ ;
1143+ if ( COPILOT_AUTH_TOKEN ) expectedListeners ++ ;
1144+ if ( GEMINI_API_KEY ) expectedListeners ++ ;
1145+ if ( OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN ) expectedListeners ++ ; // OpenCode (10004)
1146+ let readyListeners = 0 ;
1147+ function onListenerReady ( ) {
1148+ readyListeners ++ ;
1149+ if ( readyListeners === expectedListeners ) {
1150+ logRequest ( 'info' , 'startup_complete' , { message : `All ${ expectedListeners } listeners ready, starting key validation` } ) ;
1151+ validateApiKeys ( ) . catch ( ( err ) => {
1152+ logRequest ( 'error' , 'key_validation_error' , { message : 'Unexpected error during key validation' , error : String ( err ) } ) ;
1153+ keyValidationComplete = true ;
1154+ } ) ;
1155+ }
1156+ }
1157+
9261158 // OpenAI API proxy (port 10000)
9271159 if ( OPENAI_API_KEY ) {
9281160 const server = http . createServer ( ( req , res ) => {
@@ -943,6 +1175,7 @@ if (require.main === module) {
9431175
9441176 server . listen ( HEALTH_PORT , '0.0.0.0' , ( ) => {
9451177 logRequest ( 'info' , 'server_start' , { message : `OpenAI proxy listening on port ${ HEALTH_PORT } ` , target : OPENAI_API_TARGET } ) ;
1178+ onListenerReady ( ) ;
9461179 } ) ;
9471180 } else {
9481181 // No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck
@@ -960,6 +1193,7 @@ if (require.main === module) {
9601193
9611194 server . listen ( HEALTH_PORT , '0.0.0.0' , ( ) => {
9621195 logRequest ( 'info' , 'server_start' , { message : `Health endpoint listening on port ${ HEALTH_PORT } (OpenAI not configured)` } ) ;
1196+ onListenerReady ( ) ;
9631197 } ) ;
9641198 }
9651199
@@ -993,6 +1227,7 @@ if (require.main === module) {
9931227
9941228 server . listen ( 10001 , '0.0.0.0' , ( ) => {
9951229 logRequest ( 'info' , 'server_start' , { message : 'Anthropic proxy listening on port 10001' , target : ANTHROPIC_API_TARGET } ) ;
1230+ onListenerReady ( ) ;
9961231 } ) ;
9971232 }
9981233
@@ -1053,6 +1288,7 @@ if (require.main === module) {
10531288
10541289 copilotServer . listen ( 10002 , '0.0.0.0' , ( ) => {
10551290 logRequest ( 'info' , 'server_start' , { message : 'GitHub Copilot proxy listening on port 10002' } ) ;
1291+ onListenerReady ( ) ;
10561292 } ) ;
10571293 }
10581294
@@ -1087,6 +1323,7 @@ if (require.main === module) {
10871323
10881324 geminiServer . listen ( 10003 , '0.0.0.0' , ( ) => {
10891325 logRequest ( 'info' , 'server_start' , { message : 'Google Gemini proxy listening on port 10003' , target : GEMINI_API_TARGET } ) ;
1326+ onListenerReady ( ) ;
10901327 } ) ;
10911328 } else {
10921329 // No Gemini key — listen on port 10003 and return 503 so the Gemini CLI
@@ -1195,6 +1432,7 @@ if (require.main === module) {
11951432
11961433 opencodeServer . listen ( 10004 , '0.0.0.0' , ( ) => {
11971434 logRequest ( 'info' , 'server_start' , { message : `OpenCode proxy listening on port 10004 (-> ${ opencodeStartupRoute . target } )` } ) ;
1435+ onListenerReady ( ) ;
11981436 } ) ;
11991437 }
12001438
@@ -1213,4 +1451,4 @@ if (require.main === module) {
12131451}
12141452
12151453// Export for testing
1216- module . exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam } ;
1454+ module . exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys , probeProvider , httpProbe } ;
0 commit comments