feat: логирование заголовков запроса и заголовков в апстрим; маскировка секретов; Content-Type прокси-овнер

This commit is contained in:
2026-09-11 04:44:05 +03:00
parent e251ca5326
commit efdce75ee9
2 changed files with 67 additions and 1 deletions
@@ -14,7 +14,9 @@ import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.request.httpMethod
import io.ktor.server.request.receiveText import io.ktor.server.request.receiveText
import io.ktor.server.request.uri
import io.ktor.server.response.respondBytesWriter import io.ktor.server.response.respondBytesWriter
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.get import io.ktor.server.routing.get
@@ -127,6 +129,7 @@ private suspend fun handleChat(
active: Map<String, UpstreamCounter>, active: Map<String, UpstreamCounter>,
http: HttpClient, http: HttpClient,
) { ) {
log.info { "[llm-proxy] chat ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" }
val raw = call.receiveText() val raw = call.receiveText()
if (raw.isBlank()) { if (raw.isBlank()) {
call.respondText(errorJson("empty body"), ContentType.Application.Json, HttpStatusCode.BadRequest) call.respondText(errorJson("empty body"), ContentType.Application.Json, HttpStatusCode.BadRequest)
@@ -183,13 +186,21 @@ private suspend fun handleChat(
val url = provider.url.trimEnd('/') + "/chat/completions" val url = provider.url.trimEnd('/') + "/chat/completions"
val providerKey = resolveEnv(provider.key) val providerKey = resolveEnv(provider.key)
val forwardedHeaders = headersToForward(call.request.headers)
val outgoingHeaders = forwardedHeaders.toMutableMap().apply {
this["Content-Type"] = listOf("application/json")
if (providerKey.isNotEmpty()) this["Authorization"] = listOf("Bearer $providerKey")
}
log.info {
"[llm-proxy] chat model=$modelName upstream=${up.id} → $url headers: ${formatHeadersForLog(outgoingHeaders)}"
}
var failover = false var failover = false
var responded = false var responded = false
var upstreamStatus = 0 var upstreamStatus = 0
http.preparePost(url) { http.preparePost(url) {
headers { headers {
headersToForward(call.request.headers).forEach { (name, values) -> forwardedHeaders.forEach { (name, values) ->
appendAll(name, values) appendAll(name, values)
} }
// Авторизация — всегда наша (ключ провайдера из конфига); // Авторизация — всегда наша (ключ провайдера из конфига);
@@ -287,6 +298,9 @@ private val SKIP_HEADER_NAMES = setOf(
// Authorization управляется прокси явно (ключ провайдера), клиентский // Authorization управляется прокси явно (ключ провайдера), клиентский
// не пересылается // не пересылается
"authorization", "authorization",
// Content-Type всегда наш (application/json: тело мержится как JSON),
// клиентский не пересылаем, чтобы не ушло двух заголовков
"content-type",
) )
/** /**
@@ -302,6 +316,29 @@ internal fun headersToForward(request: Headers): Map<String, List<String>> =
.filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES } .filter { (name, _) -> name.lowercase() !in SKIP_HEADER_NAMES }
.associate { (name, values) -> name to values } .associate { (name, values) -> name to values }
/** Заголовки, значения которых маскируются в логах (секреты клиента). */
private val SENSITIVE_HEADER_NAMES = setOf(
"authorization",
"proxy-authorization",
"x-api-key",
"api-key",
"cookie",
"set-cookie",
)
/**
* Заголовки в виде строки для лога (`name=v1|v2, ...`). Значения чувствительных
* имён ([SENSITIVE_HEADER_NAMES]) маскируются `***`, чтобы не светить секреты.
*/
internal fun formatHeadersForLog(headers: Map<String, List<String>>): String =
headers.entries.joinToString(", ") { (name, values) ->
val shown = if (name.lowercase() in SENSITIVE_HEADER_NAMES) values.map { "***" } else values
"$name=${shown.joinToString("|")}"
}
internal fun formatHeadersForLog(headers: Headers): String =
formatHeadersForLog(headers.entries().associate { (name, values) -> name to values })
/** /**
* Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий * Сборка тела запроса: подмена `model` на реальное имя апстрима + глубокий
* послойный мерж `patch` в порядке provider → upstream → model. * послойный мерж `patch` в порядке provider → upstream → model.
@@ -422,6 +459,7 @@ internal fun pickFreeUpstream(
pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) } pool.firstOrNull { up -> up.id !in excluded && tryClaim(up, active) }
private suspend fun handleModels(call: ApplicationCall, config: Config) { private suspend fun handleModels(call: ApplicationCall, config: Config) {
log.info { "[llm-proxy] models ${call.request.httpMethod.value} ${call.request.uri} headers: ${formatHeadersForLog(call.request.headers)}" }
val created = TimeSource.Monotonic.markNow().elapsedNow().inWholeSeconds val created = TimeSource.Monotonic.markNow().elapsedNow().inWholeSeconds
val data = config.models.map { m -> val data = config.models.map { m ->
JsonObject( JsonObject(
@@ -289,6 +289,7 @@ class ConfigLogicTest {
"Proxy-Connection" to listOf("keep-alive"), "Proxy-Connection" to listOf("keep-alive"),
"Upgrade" to listOf("h2c"), "Upgrade" to listOf("h2c"),
"Authorization" to listOf("Bearer client-secret"), "Authorization" to listOf("Bearer client-secret"),
"Content-Type" to listOf("application/x-www-form-urlencoded"),
"Accept" to listOf("*/*"), "Accept" to listOf("*/*"),
) )
val out = headersToForward(req) val out = headersToForward(req)
@@ -297,6 +298,7 @@ class ConfigLogicTest {
assertEquals(listOf("*/*"), out["Accept"]) assertEquals(listOf("*/*"), out["Accept"])
assertEquals(3, out.size) assertEquals(3, out.size)
assertEquals(null, out["Authorization"]) assertEquals(null, out["Authorization"])
assertEquals(null, out["Content-Type"])
} }
@Test @Test
@@ -312,6 +314,32 @@ class ConfigLogicTest {
assertEquals(listOf("s"), out["x-opencode-session"]) assertEquals(listOf("s"), out["x-opencode-session"])
} }
@Test
fun formatHeadersForLogMasksSecretsAndKeepsOthers() {
val line = formatHeadersForLog(
headersOf(
"X-Opencode-Session" to listOf("abc-123"),
"Authorization" to listOf("Bearer super-secret"),
"x-api-key" to listOf("key-1"),
"Cookie" to listOf("session=deadbeef"),
),
)
assertTrue(line.contains("X-Opencode-Session=abc-123"))
assertTrue(line.contains("Authorization=***"))
assertTrue(line.contains("x-api-key=***"))
assertTrue(line.contains("Cookie=***"))
assertFalse(line.contains("super-secret"))
assertFalse(line.contains("deadbeef"))
}
@Test
fun formatHeadersForLogJoinsMultipleValues() {
val line = formatHeadersForLog(
headersOf("X-Custom" to listOf("a", "b")),
)
assertEquals("X-Custom=a|b", line)
}
@Test @Test
fun rebuildFromChunksPreservesAllUpstreamFields() { fun rebuildFromChunksPreservesAllUpstreamFields() {
val sse = """ val sse = """