Skip to content

게이트웨이 공유

공유 배포를 위한 클라이언트별 토큰, 그리고 프록시와 터널을 위한 SSE keepalive ping.

Updated View as Markdown

인바운드 클라이언트 토큰

기본적으로 shunt에는 인바운드 인증이 없습니다 — 루프백 전용 개인 게이트웨이에는 괜찮지만, VPN/터널을 통해 공유하는 순간, 그것에 도달할 수 있는 누구든 매핑된 모델에서 운영자의 계정을 쓸 수 있습니다(shunt가 그런 모델에 대해 자체 api_key/chatgpt_oauth 자격 증명을 주입하기 때문). 패스스루 모델은 문제가 되지 않습니다: 각 호출자 본인의 Anthropic 자격 증명을 전달합니다.

[server.auth]는 주입된 자격 증명 라우트와 모델 디스커버리를 클라이언트별 토큰으로 게이팅합니다:

[server.auth]                        # 두 키 모두 선택; 기본값 표시됨
header = "x-shunt-token"
tokens_env = "SHUNT_CLIENT_TOKENS"
# 게이트웨이 측: name:token 쌍 (이름은 로깅용 레이블; 토큰은 비밀)
export SHUNT_CLIENT_TOKENS="minsu:$(openssl rand -hex 32),alice:$(openssl rand -hex 32)"

[server.auth]가 있는데 env 변수가 설정되지 않았거나 형식이 잘못되면 시작은 닫힌 채로 실패(fail closed)합니다. 유효한 토큰 없이 매핑된 모델과 GET /v1/models에 대한 요청은 401 authentication_error를 받습니다. 두 게이트 모두 표준 Anthropic 자격 증명 슬롯 어디로든 클라이언트 토큰을 받습니다 — 구성된 헤더(기본 x-shunt-token), Authorization: Bearer, x-api-key 순의 우선순위로(여러 슬롯에 유효한 토큰이 있을 때). GET /routes, GET|HEAD /, GET /health, 그리고 패스스루 모델은 열린 채로 유지됩니다. GET /routes는 라우팅 메타데이터(구성된 프로바이더/업스트림 모델 매핑)를 노출하는 shunt 네이티브 엔드포인트이기 때문에 인증되지 않습니다 — 자격 증명은 절대 노출하지 않으며, 자격 증명은 오직 프로바이더 구성에만 존재하고 그 핸들러가 읽는 일이 없습니다.

게이팅된 라우트에서는 수락되는 자격 증명 헤더가 전달 전에 항상 제거되고(shunt가 거기에 자체 프로바이더 자격 증명을 주입합니다), 매칭은 상수 시간(constant-time)이며, 토큰 값은 절대 로깅되지 않습니다(클라이언트 이름은 요청별로 로깅됩니다). 패스스루 라우트에서는 shunt가 아무것도 주입하지 않으므로 호출자 자신의 자격 증명이 보낸 그대로 전달됩니다 — 단, 슬롯 단위로 예외가 있습니다: Authorization과 x-api-key는 각각 그 슬롯 자신의 값이 shunt 자체의 인바운드 자격 증명(수락된 클라이언트 토큰, [server.gateway] JWT, 또는 [server.admin] 자격 증명 — 관리 화면은 자체 헤더뿐 아니라 x-api-key로도 이를 수락합니다)일 때 제거됩니다. 따라서 게이트에서 수락된 자격 증명은 절대 업스트림으로 전달되지 않으면서, 다른 슬롯의 진짜 업스트림 자격 증명은 그대로 흐릅니다. 각 슬롯은 첫 번째 값만이 아니라 모든 값을 대상으로 판정하며, 어느 위치에든 shunt 자격 증명이 들어 있으면 그 슬롯을 통째로 제거합니다 — 같은 헤더를 두 번 보내면서 한 값에는 진짜 자격 증명을, 다른 값에는 shunt 자격 증명을 실었다면 둘 다 사라집니다. cookie 헤더는 전달할 때마다 헤더 통째로 제거됩니다 — 관리 화면이 세션 쿠키를 거기서 수락하고, shunt 자체는 쿠키 저장소를 두지 않기 때문입니다. 그 대가로 보낸 무해한 쿠키도 함께 사라집니다.

클라이언트 측은 게이트웨이가 무엇을 제공하는지에 따라 고르세요:

  • 풀/매핑 전용 게이트웨이(예: Anthropic 계정 풀이 기본 프로바이더): 클라이언트 토큰이 Claude Code가 이미 보내는 자격 증명 그 자체가 될 수 있습니다 — 추가 헤더 줄이 필요 없습니다:

    export ANTHROPIC_AUTH_TOKEN="<your client token>"   # Authorization: Bearer로 전송됨
  • 패스스루 모델이 섞여 있는 경우: 각 호출자의 실제 Anthropic 자격 증명에 슬롯이 하나 필요하므로, 구성된 헤더로 전용 토큰을 배포하고 Authorization / x-api-key는 그 용도로 비워 두세요(ANTHROPIC_CUSTOM_HEADERS는 한 줄당 하나의 Name: Value를 받습니다). 이는 유출을 막는 유일한 장치가 아니라 위생 수칙입니다: Authorization이나 x-api-key로 도착한 클라이언트 토큰은 — [server.gateway] JWT나 [server.admin] 자격 증명도 마찬가지로 — 패스스루 전달 전에 값 기준으로 그 슬롯에서 제거됩니다.

    export ANTHROPIC_CUSTOM_HEADERS="x-shunt-token: <your token>"

옵트인 관리자 웹 화면도 활성화한다면, 별도의 관리자 토큰으로 보호하고 HTTPS 또는 신뢰할 수 있는 터널을 통해서만 노출하세요.

자체 identity provider의 JWT 검증

조직에서 이미 identity provider를 운영한다면, [[server.auth.jwt]]로 공유 secret 대신 그 토큰을 받을 수 있습니다 — shunt가 인증 서버가 되지 않으면서 사용자별 신원을 얻는 방법입니다. shunt는 검증만 합니다: 로그인 플로우도, 세션 저장소도, 서명 secret도 없습니다. gateway 로그인과 달리 클라이언트는 여전히 base URL과 bearer만으로 구성되므로, Claude Code의 gateway provider 모드로 들어가지 않고 그 모드가 비활성화하는 기능도 잃지 않습니다.

[server.auth]
# issuer를 구성하면 정적 토큰은 없어도 됩니다.
tokens_env = "SHUNT_CLIENT_TOKENS"

[[server.auth.jwt]]
issuer = "https://accounts.google.com"
audience = ["<shunt용으로 만든 client id>"]
email_domains = ["example.com"]

각 호출자는 정적 토큰과 똑같은 자리에 토큰을 보냅니다:

export ANTHROPIC_AUTH_TOKEN="<IdP가 발급한 JWT>"   # Authorization: Bearer로 전송

JWT는 오직 Authorization: Bearer로만 받습니다 — 구성된 헤더와 x-api-key는 여전히 정적 토큰만 받습니다. 두 방식을 동시에 구성할 수 있으며, 정적 토큰을 먼저 확인합니다.

설정할 때 챙겨야 할 두 가지:

  • shunt 전용 audience를 주세요. shunt 전용 client id를 만들어 audience에 넣으면, 같은 테넌트의 다른 애플리케이션용으로 발급된 토큰이 여기서 인증되지 않습니다.
  • 수명이 짧은 토큰을 발급하세요. shunt는 취소 상태를 보관하지 않으므로 토큰은 만료될 때까지 동작합니다. max_token_age_seconds(기본 3600)는 그보다 긴 수명으로 발급된 토큰을 거부합니다 — 취소를 더 촘촘하게 하려면 이 값을 낮추고 IdP가 분 단위 토큰을 발급하게 하세요.

항목이 잘못 구성되면 시작이 닫힌 채로 실패하며, audience만 있고 email_domains/allowed_emails 규칙이 없는 경우도 여기에 해당합니다. JWT 인증이 api_error 본문과 함께 503을 반환한다면(아래 용량 초과 거부의 overloaded_error가 아니라면) shunt가 issuer의 key set을 가져오지 못한 것입니다 — 자격 증명이 아니라 issuer URL과 네트워크 도달성을 확인하세요. 전체 키 목록은 구성 레퍼런스에 있습니다.

SSE keepalive ping

미들박스는 조용한 스트림을 끊습니다 — Cloudflare의 프록시는 한 바이트도 없이 100초가 지나면 524를 반환하며(Enterprise 미만에서는 고정), 긴 추론 구간은 그만큼 조용할 수 있습니다. 그래서 shunt는 스트리밍 응답이 유휴 상태일 때마다 Anthropic 프로토콜 자체의 ping 이벤트를 주입합니다(api.anthropic.com이 직접 방출하며 모든 클라이언트가 무시하는 것):

[server]
sse_keepalive_seconds = 30   # 기본값; 0은 비활성화

Ping은 완전한 SSE 이벤트 사이에서만(절반만 보낸 프레임 안에서는 절대 안 됨), text/event-stream 응답에서만 주입되며, 업스트림 스트림과 함께 멈춥니다. 유휴 타임아웃이 없는 터널(WireGuard/Tailscale) 뒤에서는 ping이 무해합니다; 바이트 단위로 동일한 릴레이를 원한다면 0으로 비활성화하세요.

인바운드 동시성 제한

공유 게이트웨이는 여러 클라이언트를 동시에 처리하므로, shunt는 동시에 처리 중인 인바운드 요청 수에 상한을 둡니다:

[server]
max_concurrent_requests = 1024   # 기본값; 0은 제한 비활성화

상한을 넘으면 shunt는 요청을 대기열에 넣지 않고 즉시 503과 Retry-After: 1로 거부합니다. 대기 중인 요청도 버퍼링된 본문을 메모리에 그대로 들고 있으며, 이 상한은 바로 그 증가를 막기 위한 것이기 때문입니다. 스트리밍 응답은 응답 본문이 끝나거나 클라이언트 연결이 끊길 때까지 자리를 차지합니다. 즉 오래 지속되는 스트림은 응답 헤더를 만드는 동안만이 아니라 스트림이 살아 있는 내내 제한에 계산됩니다.

GET /과 GET /health는 제한에서 제외되므로, 포화 상태의 게이트웨이도 liveness 프로브에 계속 응답하며 로드 밸런서가 실제 작업을 처리 중인 인스턴스를 제거하지 않습니다.

기본값은 개인용이나 소규모 팀 게이트웨이라면 도달할 일이 없을 만큼 넉넉합니다. 트래픽이 많은 공유 배포에서는 값을 올리고, 무제한으로 되돌리려면 0으로 설정하세요. 이 제한은 시작 시점에 고정됩니다. 값 변경은 config reload가 받아들이지만 재시작해야 실제로 적용됩니다.

클라이언트가 overloaded_error 본문과 함께 503을 보고한다면 게이트웨이가 상한에 도달한 것입니다. max_concurrent_requests를 올리거나, 자리를 붙잡고 있는 멈춘 장기 스트림이 없는지 확인하세요. 거부된 요청은 shunt.requests_shed 카운터를 증가시키며, 거부 자체는 debug!로 로깅됩니다.

이 상한은 바이트가 아니라 요청 수를 셉니다. 그리고 permit은 요청이 처리 중인 내내 유지됩니다. 인터넷에 노출되는 배포에서 고려할 점 두 가지:

  • 허용된 요청 하나가 최대 64 MiB 본문을 버퍼링할 수 있으므로, 바이트 예산은 상한에 비례해 늘어납니다. 호스트가 그 곱을 감당할 수 없다면 max_concurrent_requests를 낮추세요.
  • 요청 본문을 아주 천천히 보내는 클라이언트나 멈춘 업스트림 스트림은 그동안 자리를 계속 차지합니다. shunt는 인바운드 요청 타임아웃을 적용하지 않습니다. 게이트웨이가 노출되어 있다면 엣지 프록시에서 느린 클라이언트를 끊으세요.

둘 다 이 제한 때문에 새로 생긴 문제는 아닙니다(상한 덕분에 이전에는 무한이던 천장이 유한해졌습니다). 다만 max_concurrent_requests만으로는 메모리 상한이 되지 않는다는 뜻입니다.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close