refactor(oci): to one image
This commit is contained in:
parent
7ff2e4ddce
commit
ee27e4c676
18 changed files with 342 additions and 633 deletions
|
|
@ -6,14 +6,14 @@ TZ=UTC
|
||||||
# Admin auth
|
# Admin auth
|
||||||
ADMIN_AUTH_MODE=both
|
ADMIN_AUTH_MODE=both
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
# Example for password "change-me": sha256$4d7c51b1efe9047b4978a321b9b4d7c0b4d6a8d4f1f6f7e3b6a5a7b2f4f7d246
|
# Example for password "change-me": sha256$e2186dbdb1bb4193608605e84f33208765b5693b55edd4f730a719a100eeea6f
|
||||||
ADMIN_PASSWORD_HASH=sha256$4d7c51b1efe9047b4978a321b9b4d7c0b4d6a8d4f1f6f7e3b6a5a7b2f4f7d246
|
ADMIN_PASSWORD_HASH=sha256$e2186dbdb1bb4193608605e84f33208765b5693b55edd4f730a719a100eeea6f
|
||||||
ADMIN_SESSION_SECRET=replace-with-a-long-random-secret
|
ADMIN_SESSION_SECRET=replace-with-a-long-random-secret
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
ADMIN_SESSION_TTL_HOURS=12
|
||||||
ADMIN_API_TOKEN_TTL_DAYS=30
|
ADMIN_API_TOKEN_TTL_DAYS=30
|
||||||
|
|
||||||
# Admin gateway / proxy hardening
|
# Admin gateway / proxy hardening
|
||||||
CORS_ORIGINS=http://localhost:3002,http://127.0.0.1:3002
|
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||||
ADMIN_TRUSTED_PROXY_IPS=
|
ADMIN_TRUSTED_PROXY_IPS=
|
||||||
|
|
||||||
# OpenID Connect
|
# OpenID Connect
|
||||||
|
|
|
||||||
100
.forgejo/workflows/publish-images.yml
Normal file
100
.forgejo/workflows/publish-images.yml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
name: Publish Container Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-images:
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive registry metadata
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
registry="${GITHUB_SERVER_URL#https://}"
|
||||||
|
registry="${registry#http://}"
|
||||||
|
owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
repo="$(printf '%s' "${GITHUB_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ref_name="$(printf '%s' "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')"
|
||||||
|
short_sha="$(printf '%s' "${GITHUB_SHA}" | cut -c1-12)"
|
||||||
|
|
||||||
|
echo "registry=${registry}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "owner=${owner}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repo=${repo}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ref_name=${ref_name}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=${short_sha}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to Forgejo container registry
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ steps.meta.outputs.registry }}
|
||||||
|
REGISTRY_USERNAME: ${{ github.actor }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.FORGEJO_PACKAGES_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${REGISTRY_PASSWORD}" ]; then
|
||||||
|
echo "FORGEJO_PACKAGES_TOKEN secret is required to push images." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" --username "${REGISTRY_USERNAME}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push images
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ steps.meta.outputs.registry }}
|
||||||
|
OWNER: ${{ steps.meta.outputs.owner }}
|
||||||
|
IMAGE: ${{ steps.meta.outputs.registry }}/${{ steps.meta.outputs.owner }}/${{ steps.meta.outputs.repo }}
|
||||||
|
REF_NAME: ${{ steps.meta.outputs.ref_name }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
REPOSITORY_URL: ${{ env.REPOSITORY_URL }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tags=()
|
||||||
|
tags+=("${IMAGE}:sha-${SHORT_SHA}")
|
||||||
|
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "branch" ]; then
|
||||||
|
tags+=("${IMAGE}:branch-${REF_NAME}")
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
tags+=("${IMAGE}:latest")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
tags+=("${IMAGE}:${REF_NAME}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker_args=(
|
||||||
|
build
|
||||||
|
.
|
||||||
|
--file Dockerfile
|
||||||
|
--target app
|
||||||
|
--label "org.opencontainers.image.source=${REPOSITORY_URL}"
|
||||||
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
docker_args+=(--tag "${tag}")
|
||||||
|
done
|
||||||
|
|
||||||
|
docker "${docker_args[@]}"
|
||||||
|
|
||||||
|
for tag in "${tags[@]}"; do
|
||||||
|
docker push "${tag}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The single OCI image contains the public API and the admin dashboard runtime.
|
||||||
22
AGENTS.md
22
AGENTS.md
|
|
@ -10,7 +10,7 @@ pnpm monorepo | Express 5 + TypeScript | Solid.js + Vite | SQLite (better-sqlite
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ Express 백엔드 (포트 3000)
|
server/ Express 백엔드 (포트 3000)
|
||||||
client/ Solid.js 어드민 대시보드 (포트 3002)
|
client/ Solid.js 관리자 대시보드 소스
|
||||||
shared/ 공유 TypeScript 타입
|
shared/ 공유 TypeScript 타입
|
||||||
database/ SQL 스키마 원본
|
database/ SQL 스키마 원본
|
||||||
docs/ 세부 문서
|
docs/ 세부 문서
|
||||||
|
|
@ -26,25 +26,23 @@ scripts/ 개발 스크립트
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
**인증**: `Authorization: Bearer <api_key>` → `auth.ts` 미들웨어 → 사용자 식별 + 권한 로드. 이 키는 라우터 접속용이며, 업스트림 요청에는 전달되지 않는다. 업스트림 `Authorization`은 `backends.api_key`가 있을 때만 해당 값으로 주입된다.
|
**사용자 인증**: `Authorization: Bearer <api_key>` -> `auth.ts` 미들웨어 -> 사용자 식별 + 권한 로드. 이 키는 라우터 접속용이며, 업스트림 요청에는 전달되지 않는다. 업스트림 `Authorization`은 `backends.api_key`가 있을 때만 해당 값으로 주입된다.
|
||||||
|
|
||||||
**관리자 인증**: 관리자 프론트와 `/admin/**` API는 별도 관리자 인증을 사용한다. 브라우저는 서버사이드 세션 + `HttpOnly` 쿠키, 자동화는 관리자 API 토큰을 사용하며, 인증 모드는 `ADMIN_AUTH_MODE=env|oidc|both` 로 제어한다.
|
**관리자 인증**: 관리자 프론트(`/dashboard`)와 `/admin/**` API는 별도 관리자 인증을 사용한다. 브라우저는 서버사이드 세션 + `HttpOnly` 쿠키, 자동화는 관리자 API 토큰을 사용하고, 인증 모드는 `ADMIN_AUTH_MODE=env|oidc|both` 로 제어한다.
|
||||||
|
|
||||||
**요청 흐름**:
|
**요청 흐름**:
|
||||||
```
|
```
|
||||||
Client → Auth → Script(onRequest) → RouterService → Backend → Script(onResponse) → Response
|
Client -> Auth -> Script(onRequest) -> RouterService -> Backend -> Script(onResponse) -> Response
|
||||||
→ AnalyticsService(log)
|
-> AnalyticsService(log)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Script Engine**: isolated-vm 샌드박스에서 JS 실행. 3가지 타입: per-user, per-backend, per-user-backend
|
**Script Engine**: isolated-vm 샌드박스에서 JS 실행. 3가지 타입: per-user, per-backend, per-user-backend
|
||||||
|
|
||||||
**Database**: `DB_DIR` 하위에 `core.db` (users, backends, permissions, user_scripts), `analytics.db` (usage_stats, backend_metrics), `request_logs/request_logs_YYYY-MM.db` (상세 요청 로그)
|
**Database**: `DB_DIR` 하위에 `core.db` (users, backends, permissions, user_scripts, admin_sessions, admin_api_tokens), `analytics.db` (usage_stats, backend_metrics), `request_logs/request_logs_YYYY-MM.db` (상세 요청 로그)
|
||||||
|
|
||||||
**관리자 세션/토큰 저장**: `core.db` 안에 `admin_sessions`, `admin_api_tokens` 테이블이 생성되며, 관리자 로그인 세션과 자동화용 관리자 토큰을 저장한다.
|
|
||||||
|
|
||||||
**상세 로그 조회**: `month` 또는 `date`를 지정하면 해당 월 DB 1개만 조회한다. 둘 다 없으면 최신 월부터 월별 DB를 순차 조회하며 `offset`/`limit`을 적용한다.
|
**상세 로그 조회**: `month` 또는 `date`를 지정하면 해당 월 DB 1개만 조회한다. 둘 다 없으면 최신 월부터 월별 DB를 순차 조회하며 `offset`/`limit`을 적용한다.
|
||||||
|
|
||||||
**배포 분리**: 권장 운영 배포는 public/admin 게이트웨이 2개 진입점이다. public gateway는 `/v1/**`, `/health`만 외부 공개하고, admin gateway는 내부망/VPN에서만 관리자 프론트와 `/admin/**`를 제공한다.
|
**단일 이미지 배포**: 권장 배포는 단일 OCI 이미지가 `/v1/**`, `/health`, `/admin/**`, `/dashboard`를 함께 제공하는 구조다. docker-compose에서는 단일 포트만 노출하고, Kubernetes + Traefik에서는 path 기반 정책으로 `/admin/**`, `/dashboard/**`를 내부망 전용으로 제한한다.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
@ -66,7 +64,7 @@ pnpm run bench # 벤치마크 실행
|
||||||
| `ADMIN_AUTH_MODE` | `both` | 관리자 로그인 모드 (`env`, `oidc`, `both`) |
|
| `ADMIN_AUTH_MODE` | `both` | 관리자 로그인 모드 (`env`, `oidc`, `both`) |
|
||||||
| `ADMIN_USERNAME` | `admin` 예시 | ENV 관리자 로그인 아이디 |
|
| `ADMIN_USERNAME` | `admin` 예시 | ENV 관리자 로그인 아이디 |
|
||||||
| `ADMIN_PASSWORD_HASH` | (권장 필수) | 관리자 비밀번호 hash (`sha256$...` 또는 `scrypt$...`) |
|
| `ADMIN_PASSWORD_HASH` | (권장 필수) | 관리자 비밀번호 hash (`sha256$...` 또는 `scrypt$...`) |
|
||||||
| `ADMIN_SESSION_SECRET` | (필수) | 관리자 세션/토큰 hash salt 용 비밀값 |
|
| `ADMIN_SESSION_SECRET` | (필수) | 관리자 세션/토큰 hash salt 및 비밀값 |
|
||||||
| `ADMIN_SESSION_TTL_HOURS` | `12` | 관리자 세션 만료 시간 |
|
| `ADMIN_SESSION_TTL_HOURS` | `12` | 관리자 세션 만료 시간 |
|
||||||
| `ADMIN_API_TOKEN_TTL_DAYS` | `30` | 관리자 API 토큰 만료 일수 |
|
| `ADMIN_API_TOKEN_TTL_DAYS` | `30` | 관리자 API 토큰 만료 일수 |
|
||||||
| `ADMIN_TRUSTED_PROXY_IPS` | empty | 관리자 경로 접근을 허용할 프록시 IP 목록 |
|
| `ADMIN_TRUSTED_PROXY_IPS` | empty | 관리자 경로 접근을 허용할 프록시 IP 목록 |
|
||||||
|
|
@ -80,7 +78,7 @@ pnpm run bench # 벤치마크 실행
|
||||||
## Detailed Docs
|
## Detailed Docs
|
||||||
|
|
||||||
클라이언트 중심
|
클라이언트 중심
|
||||||
- [docs/client.md](docs/client.md) — 클라이언트 구조, 라우트, 컴포넌트
|
- [docs/client.md](docs/client.md) — 클라이언트 구조, `/dashboard` 라우팅, 관리자 UI 동작
|
||||||
- [docs/frontend-design.md](docs/frontend-design.md) — 프론트엔드 디자인 가이드
|
- [docs/frontend-design.md](docs/frontend-design.md) — 프론트엔드 디자인 가이드
|
||||||
- [docs/admin-auth.md](docs/admin-auth.md) — 관리자 인증, 세션, CSRF, 관리자 토큰
|
- [docs/admin-auth.md](docs/admin-auth.md) — 관리자 인증, 세션, CSRF, 관리자 토큰
|
||||||
- [docs/oidc.md](docs/oidc.md) — OpenID Connect 설정과 allowlist 정책
|
- [docs/oidc.md](docs/oidc.md) — OpenID Connect 설정과 allowlist 정책
|
||||||
|
|
@ -89,6 +87,6 @@ pnpm run bench # 벤치마크 실행
|
||||||
- [docs/server.md](docs/server.md) — 서버 구조, 서비스, 모델, 의존성
|
- [docs/server.md](docs/server.md) — 서버 구조, 서비스, 모델, 의존성
|
||||||
- [docs/database.md](docs/database.md) — DB 테이블 스키마 전체
|
- [docs/database.md](docs/database.md) — DB 테이블 스키마 전체
|
||||||
- [docs/api.md](docs/api.md) — API 엔드포인트 레퍼런스
|
- [docs/api.md](docs/api.md) — API 엔드포인트 레퍼런스
|
||||||
- [docs/k8s-traefik.md](docs/k8s-traefik.md) — Traefik IngressRoute, 내부망 IP allowlist 기반 Kubernetes 배포 예시
|
- [docs/k8s-traefik.md](docs/k8s-traefik.md) — Traefik path 기반 내부망 제어 예시
|
||||||
- [docs/scripts.md](docs/scripts.md) — Script Engine 사용법, 타입, 예제
|
- [docs/scripts.md](docs/scripts.md) — Script Engine 사용법, 타입, 예제
|
||||||
- [docs/benchmarks.md](docs/benchmarks.md) — benchmark CLI 사용법
|
- [docs/benchmarks.md](docs/benchmarks.md) — benchmark CLI 사용법
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -24,7 +24,7 @@ COPY scripts ./scripts
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-bookworm-slim AS server
|
FROM node:${NODE_VERSION}-bookworm-slim AS app
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ COPY --from=build /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/package.json ./package.json
|
COPY --from=build /app/package.json ./package.json
|
||||||
COPY --from=build /app/server/package.json ./server/package.json
|
COPY --from=build /app/server/package.json ./server/package.json
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
|
COPY --from=build /app/client/dist ./client/dist
|
||||||
COPY --from=build /app/database ./database
|
COPY --from=build /app/database ./database
|
||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
@ -48,16 +49,3 @@ RUN mkdir -p /data
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "server/dist/server/src/index.js"]
|
CMD ["node", "server/dist/server/src/index.js"]
|
||||||
|
|
||||||
FROM nginx:1.28-alpine AS admin-gateway
|
|
||||||
|
|
||||||
COPY docker/admin-nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /app/client/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
FROM nginx:1.28-alpine AS public-gateway
|
|
||||||
|
|
||||||
COPY docker/public-nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ function AuthenticatedApp() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
|
<Show when={auth.session()?.authenticated} fallback={<LoginGate />}>
|
||||||
<Router>
|
<Router base="/dashboard">
|
||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
<Route path="/users" component={Users} />
|
<Route path="/users" component={Users} />
|
||||||
<Route path="/backends" component={Backends} />
|
<Route path="/backends" component={Backends} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
|
||||||
import solidPlugin from 'vite-plugin-solid';
|
import solidPlugin from 'vite-plugin-solid';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/dashboard/',
|
||||||
plugins: [solidPlugin()],
|
plugins: [solidPlugin()],
|
||||||
server: {
|
server: {
|
||||||
port: 3002,
|
port: 3002,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
services:
|
services:
|
||||||
server:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: server
|
target: app
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
SERVER_PORT: 3000
|
SERVER_PORT: 3000
|
||||||
DB_DIR: /data
|
DB_DIR: /data
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
CORS_ORIGINS: http://localhost:3002,http://127.0.0.1:3002
|
CORS_ORIGINS: http://localhost:3000,http://127.0.0.1:3000
|
||||||
ADMIN_AUTH_MODE: ${ADMIN_AUTH_MODE:-both}
|
ADMIN_AUTH_MODE: ${ADMIN_AUTH_MODE:-both}
|
||||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-}
|
||||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||||
|
|
@ -23,6 +23,8 @@ services:
|
||||||
ADMIN_TRUSTED_PROXY_IPS: ${ADMIN_TRUSTED_PROXY_IPS:-}
|
ADMIN_TRUSTED_PROXY_IPS: ${ADMIN_TRUSTED_PROXY_IPS:-}
|
||||||
volumes:
|
volumes:
|
||||||
- router-data:/data
|
- router-data:/data
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
|
|
@ -30,35 +32,5 @@ services:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
public-gateway:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: public-gateway
|
|
||||||
depends_on:
|
|
||||||
- server
|
|
||||||
ports:
|
|
||||||
- "3000:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/health || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
admin-gateway:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
target: admin-gateway
|
|
||||||
depends_on:
|
|
||||||
- server
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3002:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
router-data:
|
router-data:
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /admin/ {
|
|
||||||
proxy_pass http://server:3000/admin/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
location = /health {
|
|
||||||
proxy_pass http://server:3000/health;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /v1/ {
|
|
||||||
proxy_pass http://server:3000/v1/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,25 @@
|
||||||
# Admin Authentication
|
# Admin Authentication
|
||||||
|
|
||||||
관리자 영역은 `/admin/**` API와 관리자 프론트엔드 전체를 포함한다.
|
관리자 표면은 아래 두 부분으로 구성된다.
|
||||||
`/v1/**` 와 `/health` 는 기존 사용자 API 키 인증 또는 공개 엔드포인트 계약을 유지한다.
|
|
||||||
|
- 관리자 API: `/admin/**`
|
||||||
|
- 관리자 UI: `/dashboard`, `/dashboard/**`
|
||||||
|
|
||||||
|
OpenAI 호환 라우터 표면은 그대로 유지된다.
|
||||||
|
|
||||||
|
- `/v1/**`
|
||||||
|
- `/health`
|
||||||
|
|
||||||
## Authentication Modes
|
## Authentication Modes
|
||||||
|
|
||||||
`ADMIN_AUTH_MODE` 로 관리자 로그인 방식을 제어한다.
|
`ADMIN_AUTH_MODE` 가 사용 가능한 관리자 로그인 방식을 제어한다.
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `env` | ENV 기반 관리자 아이디/비밀번호 로그인만 허용 |
|
| `env` | 아이디/비밀번호 로그인만 허용 |
|
||||||
| `oidc` | OpenID Connect 로그인만 허용 |
|
| `oidc` | OpenID Connect 로그인만 허용 |
|
||||||
| `both` | ENV 로그인과 OIDC 로그인을 모두 허용 |
|
| `both` | ENV 로그인과 OIDC 로그인을 모두 허용 |
|
||||||
|
|
||||||
기본 추천값은 `both`.
|
|
||||||
|
|
||||||
## Protected Surface
|
## Protected Surface
|
||||||
|
|
||||||
아래 경로는 관리자 인증이 필요하다.
|
아래 경로는 관리자 인증이 필요하다.
|
||||||
|
|
@ -26,7 +31,7 @@
|
||||||
- `/admin/analytics/*`
|
- `/admin/analytics/*`
|
||||||
- `/admin/health`
|
- `/admin/health`
|
||||||
|
|
||||||
예외 공개 경로:
|
관리자 인증 내부의 공개 예외 경로:
|
||||||
|
|
||||||
- `POST /admin/auth/login`
|
- `POST /admin/auth/login`
|
||||||
- `GET /admin/auth/session`
|
- `GET /admin/auth/session`
|
||||||
|
|
@ -35,98 +40,55 @@
|
||||||
|
|
||||||
## Session Model
|
## Session Model
|
||||||
|
|
||||||
브라우저 로그인은 서버사이드 세션 + `HttpOnly` 쿠키를 사용한다.
|
- 브라우저 인증은 서버사이드 세션 + `HttpOnly` 쿠키를 사용한다
|
||||||
|
|
||||||
- 쿠키 이름: `kyush_admin_session`
|
- 쿠키 이름: `kyush_admin_session`
|
||||||
- `SameSite=Lax`
|
|
||||||
- `Secure`: production 환경에서 활성화
|
|
||||||
- 세션 TTL: `ADMIN_SESSION_TTL_HOURS`
|
- 세션 TTL: `ADMIN_SESSION_TTL_HOURS`
|
||||||
|
- 세션 레코드는 `core.db` 의 `admin_sessions` 테이블에 저장된다
|
||||||
세션 데이터는 `core.db` 의 `admin_sessions` 테이블에 저장된다.
|
|
||||||
|
|
||||||
## CSRF
|
## CSRF
|
||||||
|
|
||||||
세션 기반 관리자 요청은 CSRF 보호를 적용한다.
|
- `GET /admin/auth/session` 응답에 `csrfToken` 이 포함된다
|
||||||
|
- 세션 기반 `POST`, `PUT`, `DELETE` 요청은 `X-CSRF-Token` 을 보내야 한다
|
||||||
- `GET /admin/auth/session` 응답에 `csrfToken` 이 포함된다.
|
- Bearer 관리자 API 토큰 요청에는 CSRF를 적용하지 않는다
|
||||||
- 프론트엔드는 `POST`, `PUT`, `DELETE` 요청마다 `X-CSRF-Token` 헤더를 보낸다.
|
|
||||||
- Bearer 관리자 API 토큰 요청에는 CSRF를 적용하지 않는다.
|
|
||||||
|
|
||||||
## Admin API Tokens
|
## Admin API Tokens
|
||||||
|
|
||||||
자동화나 서비스 연동은 관리자 API 토큰을 사용할 수 있다.
|
- 발급: `POST /admin/auth/tokens`
|
||||||
|
- 조회: `GET /admin/auth/tokens`
|
||||||
|
- 폐기: `DELETE /admin/auth/tokens/:id`
|
||||||
|
|
||||||
- 발급 API: `POST /admin/auth/tokens`
|
원본 토큰은 생성 시 1회만 반환된다. DB에는 hash와 prefix만 저장된다.
|
||||||
- 조회 API: `GET /admin/auth/tokens`
|
|
||||||
- 폐기 API: `DELETE /admin/auth/tokens/:id`
|
|
||||||
|
|
||||||
토큰은 최초 발급 시 1회만 원문이 반환된다.
|
|
||||||
DB에는 hash 와 prefix 만 저장된다.
|
|
||||||
|
|
||||||
대상 테이블:
|
|
||||||
|
|
||||||
- `admin_api_tokens`
|
|
||||||
|
|
||||||
## ENV Login
|
## ENV Login
|
||||||
|
|
||||||
ENV 로그인은 아래 설정이 필요하다.
|
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `ADMIN_USERNAME` | 관리자 로그인 아이디 |
|
| `ADMIN_USERNAME` | 관리자 로그인 아이디 |
|
||||||
| `ADMIN_PASSWORD_HASH` | 비밀번호 hash |
|
| `ADMIN_PASSWORD_HASH` | 비밀번호 hash |
|
||||||
| `ADMIN_SESSION_SECRET` | 세션/토큰 hash salt 및 비밀값 |
|
| `ADMIN_SESSION_SECRET` | 세션/토큰용 비밀값 |
|
||||||
|
|
||||||
현재 구현은 아래 hash 형식을 지원한다.
|
지원하는 hash 형식:
|
||||||
|
|
||||||
- `sha256$<hex>`
|
- `sha256$<hex>`
|
||||||
- `scrypt$<salt_hex>$<derived_hex>`
|
- `scrypt$<salt_hex>$<derived_hex>`
|
||||||
|
|
||||||
`ADMIN_PASSWORD_HASH` 는 평문 비밀번호 대신 hash 값으로만 저장하는 것을 전제로 한다.
|
### Password Hash Generation Examples
|
||||||
|
|
||||||
## Admin Session API
|
빠른 로컬 테스트용 `sha256` 예시:
|
||||||
|
|
||||||
### `GET /admin/auth/session`
|
```bash
|
||||||
|
node -e "const crypto=require('crypto'); const password=process.argv[1]; console.log('sha256$'+crypto.createHash('sha256').update(password).digest('hex'))" "change-me"
|
||||||
현재 관리자 세션 정보를 반환한다.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"authenticated": true,
|
|
||||||
"authMode": "both",
|
|
||||||
"csrfToken": "base64url-token",
|
|
||||||
"principal": {
|
|
||||||
"provider": "env",
|
|
||||||
"subject": "env:admin",
|
|
||||||
"username": "admin",
|
|
||||||
"displayName": "admin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `POST /admin/auth/login`
|
운영 권장 `scrypt` 예시:
|
||||||
|
|
||||||
요청 본문:
|
```bash
|
||||||
|
node -e "const crypto=require('crypto'); const password=process.argv[1]; const salt=crypto.randomBytes(16).toString('hex'); const derived=crypto.scryptSync(password, Buffer.from(salt,'hex'), 64).toString('hex'); console.log(`scrypt$${salt}$${derived}`)" "change-me"
|
||||||
```json
|
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "your-password"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
성공 시 세션 쿠키를 발급하고 `AdminSessionResponse` 를 반환한다.
|
## UI Flow
|
||||||
|
|
||||||
### `POST /admin/auth/logout`
|
- 브라우저 진입 경로는 `/dashboard`
|
||||||
|
- 관리자 SPA는 `/admin/**` 로 관리자 API를 호출한다
|
||||||
현재 세션을 무효화하고 세션 쿠키를 제거한다.
|
- 로그인 성공 후 기본 이동 경로는 `/dashboard`
|
||||||
|
- OIDC `next` 값은 `/dashboard` 경로들로 제한된다
|
||||||
## Deployment Notes
|
|
||||||
|
|
||||||
운영 배포는 관리자 영역과 공개 라우팅을 분리하는 구성이 권장된다.
|
|
||||||
|
|
||||||
- public gateway: `/v1/**`, `/health`
|
|
||||||
- admin gateway: 관리자 SPA + `/admin/**`
|
|
||||||
|
|
||||||
관리자 프론트는 내부 전용 origin 에서 `/admin/**` 상대 경로만 호출한다.
|
|
||||||
이 구조로 브라우저 CORS 복잡도를 줄이고 관리자 라우팅을 외부 공개하지 않을 수 있다.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Client (Solid.js + Vite)
|
# Client (Solid.js + Vite)
|
||||||
|
|
||||||
Admin 대시보드 진입점: `client/src/index.tsx`
|
관리자 대시보드 진입점: `client/src/index.tsx`
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
|
|
||||||
|
|
@ -14,77 +14,50 @@ client/src/
|
||||||
types/
|
types/
|
||||||
index.ts # TypeScript 타입 정의
|
index.ts # TypeScript 타입 정의
|
||||||
routes/
|
routes/
|
||||||
Dashboard.tsx # 메인 운영 요약 스트립 + 최근 요청 테이블 + 관리자 토큰 관리
|
Dashboard.tsx # 운영 요약, 최근 요청, 관리자 토큰 관리
|
||||||
Users.tsx # 사용자 관리, CRUD, API 키 재발급, 개별 상세 로그 토글
|
Users.tsx # 사용자 CRUD
|
||||||
Backends.tsx # 백엔드 관리, CRUD (name, base_url, api_key, detail_logging, is_active)
|
Backends.tsx # 백엔드 CRUD
|
||||||
Permissions.tsx # 권한 관리, user-backend 매핑, 추가/해제
|
Permissions.tsx # 권한 매핑 관리
|
||||||
Analytics.tsx # 분석 탭, 최근 요청, 사용량 통계, 백엔드 메트릭 차트/표
|
Analytics.tsx # 분석 화면
|
||||||
DetailLogs.tsx # 상세 로그 검색 뷰, 텍스트 검색, 사용자/백엔드 필터 + 페이지 + 페이로드 인스펙터
|
DetailLogs.tsx # 상세 요청 로그 탐색
|
||||||
Scripts.tsx # 스크립트 관리, 목록, 요약, 편집기, 테스트, 활성/비활성화
|
Scripts.tsx # 스크립트 관리 및 테스트
|
||||||
components/
|
components/
|
||||||
Layout.tsx # AppShell 래퍼
|
LoginGate.tsx # 로그인 화면, ENV 로그인 폼, OIDC 버튼
|
||||||
EditModal.tsx # 레거시 범용 편집 모달
|
|
||||||
ScriptEditor.tsx # Monaco 에디터 래퍼 (TypeScript 하이라이트)
|
|
||||||
LoginGate.tsx # 로그인 화면, ENV 로그인 폼, OIDC 시작 버튼
|
|
||||||
ui/
|
ui/
|
||||||
index.ts # primitives/patterns 재수출
|
patterns/ # AppShell 등 페이지 패턴
|
||||||
primitives/ # Button, Dialog, Select, Tabs, TextField 등
|
primitives/ # 공통 입력/표시 컴포넌트
|
||||||
patterns/ # AppShell, DataGrid, PageHeader, SummaryStrip 등
|
styles/ # CSS 레이어
|
||||||
styles/ # token/base/layout/pattern CSS 레이어
|
|
||||||
stories/ # Storybook workbench 스토리
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
|
관리자 UI의 브라우저 진입 경로는 `/dashboard` 이다.
|
||||||
|
|
||||||
| URL | Component | Description |
|
| URL | Component | Description |
|
||||||
|-----|-----------|-------------|
|
|-----|-----------|-------------|
|
||||||
| `/` | Dashboard | 시스템 개요 |
|
| `/dashboard` | Dashboard | 시스템 개요 |
|
||||||
| `/users` | Users | 사용자 관리 |
|
| `/dashboard/users` | Users | 사용자 관리 |
|
||||||
| `/backends` | Backends | 백엔드 관리 |
|
| `/dashboard/backends` | Backends | 백엔드 관리 |
|
||||||
| `/permissions` | Permissions | 권한 관리 |
|
| `/dashboard/permissions` | Permissions | 권한 관리 |
|
||||||
| `/analytics` | Analytics | 집계 기반 분석 대시보드 |
|
| `/dashboard/analytics` | Analytics | 분석 대시보드 |
|
||||||
| `/detail-logs` | DetailLogs | 상세 요청 로그 검색/인스펙션 |
|
| `/dashboard/detail-logs` | DetailLogs | 상세 요청 로그 탐색 |
|
||||||
| `/scripts` | Scripts | 스크립트 관리 |
|
| `/dashboard/scripts` | Scripts | 스크립트 관리 |
|
||||||
|
|
||||||
모든 관리자 라우트는 로그인 게이트 아래에서만 렌더링된다.
|
모든 관리자 라우트는 로그인 게이트 아래에서 렌더링된다.
|
||||||
|
|
||||||
## Styling
|
## Dev And Production
|
||||||
|
|
||||||
공통 UI 레이어는 `client/src/ui/styles.css` 에서 시작하고, 내부적으로 `tokens.css`, `base.css`, `layout.css`, `patterns.css`, `pages.css` 를 불러온다.
|
- 개발 서버 포트: `3002`
|
||||||
|
- 개발 API 프록시: `/admin` -> `http://localhost:3000`
|
||||||
|
- 운영에서는 Express 서버가 빌드된 `client/dist`를 직접 서빙한다
|
||||||
|
- 운영 브라우저 진입 경로: `http://<host>:3000/dashboard`
|
||||||
|
|
||||||
- `@kobalte/core` 기반 primitive wrapper와 공통 pattern을 사용한다.
|
SPA는 `/dashboard`를 라우터 base로 사용하고, 관리자 API는 계속 `/admin/**`로 호출한다.
|
||||||
- 페이지는 `AppShell`, `PageHeader`, `Panel`, `DataGrid`, `SummaryStrip`, `FormDialog`, `ConfirmDialog` 조합으로 구성된다.
|
|
||||||
- Storybook(`client/.storybook`)에서 같은 스타일 레이어를 사용하는 workbench를 운영한다.
|
|
||||||
- 라이트 테마와 dense 콘솔형 레이아웃을 기본 전제로 둔다.
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| solid-js | UI 프레임워크 |
|
|
||||||
| @solidjs/router | 클라이언트 사이드 라우터 |
|
|
||||||
| @kobalte/core | headless UI primitive |
|
|
||||||
| lucide-solid | 아이콘 세트 |
|
|
||||||
| solid-monaco | Monaco 에디터 통합 |
|
|
||||||
| vite | 빌드 도구 + 개발 서버 |
|
|
||||||
|
|
||||||
## Dev Server
|
|
||||||
|
|
||||||
포트: 3002 (`vite.config.ts`), 개발 중 API 프록시 `/admin` → `http://localhost:3000`
|
|
||||||
|
|
||||||
운영 배포에서는 관리자 프론트가 admin gateway 뒤에서 same-origin `/admin/**`를 호출한다. 브라우저가 내부 서버 주소를 직접 알 필요는 없다.
|
|
||||||
|
|
||||||
## Admin Auth Notes
|
## Admin Auth Notes
|
||||||
|
|
||||||
- 앱 시작 시 `GET /admin/auth/session`으로 현재 로그인 상태와 CSRF 토큰을 불러온다.
|
- 앱 시작 시 `GET /admin/auth/session`으로 세션을 복구한다
|
||||||
- 인증되지 않은 상태에서는 관리자 화면 대신 `LoginGate`가 렌더링된다.
|
- 인증되지 않은 상태에서는 `LoginGate`가 렌더링된다
|
||||||
- ENV 로그인 폼과 OIDC 로그인 버튼을 함께 제공한다.
|
- ENV 로그인과 OIDC 로그인을 함께 사용할 수 있다
|
||||||
- 세션 기반 변경 요청은 `X-CSRF-Token` 헤더를 자동으로 포함한다.
|
- 세션 기반 쓰기 요청에는 `X-CSRF-Token`이 자동 포함된다
|
||||||
- 401 응답은 재로그인 흐름으로, 403 응답은 권한 오류 표시로 처리한다.
|
- 401 응답이 오면 UI는 로그인 상태로 되돌아간다
|
||||||
- 관리자 API 토큰 생성/폐기는 Dashboard에서 수행한다.
|
|
||||||
|
|
||||||
## Analytics Notes
|
|
||||||
|
|
||||||
- `Analytics` 화면은 최근 요청/사용량 메트릭의 집계 뷰를 보여준다.
|
|
||||||
- 상세 요청 로그 검색은 별도 `DetailLogs` 화면에서 처리한다.
|
|
||||||
- analytics API 클라이언트는 `month`, `date`, `q`, `limit`, `offset`, `userId`, `backendId`, `endpoint`, `detailLogged` 필터를 지원한다.
|
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,57 @@
|
||||||
# Kubernetes Deployment with Traefik
|
# Kubernetes Deployment with Traefik
|
||||||
|
|
||||||
`kyush-llm-router`를 Kubernetes에 배포하면서, 외부에는 라우터 API만 공개하고 관리자 프론트와 `/admin/**`는 내부망에서만 접근하도록 구성하는 예시다.
|
이 예시는 아래를 함께 담은 단일 OCI 이미지를 가정한다.
|
||||||
|
|
||||||
이 문서는 ingress controller로 Traefik을 사용하고, 관리자 경로에는 `IngressRoute`와 `Middleware.ipAllowList`를 적용하는 상황을 가정한다.
|
- Express API 서버
|
||||||
|
- 빌드된 관리자 대시보드 자산
|
||||||
|
|
||||||
## Goals
|
Traefik은 path 기반으로 공개/내부 경로를 분리한다.
|
||||||
|
|
||||||
- 공개 진입점
|
- 공개: `/v1/**`, `/health`
|
||||||
- `/v1/**`
|
- 내부 전용: `/admin/**`, `/dashboard`, `/dashboard/**`
|
||||||
- `/health`
|
|
||||||
- 내부 전용 진입점
|
|
||||||
- 관리자 프론트
|
|
||||||
- `/admin/**`
|
|
||||||
- 브라우저 기준 관리자 프론트와 관리자 API는 same-origin으로 동작
|
|
||||||
- Traefik에서 admin host에 IP allowlist를 적용
|
|
||||||
|
|
||||||
## Assumptions
|
## Topology
|
||||||
|
|
||||||
- Traefik CRD가 이미 설치되어 있다.
|
|
||||||
- 예시는 `apiVersion: traefik.io/v1alpha1` 기준이다.
|
|
||||||
- public host는 `router.example.com`, admin host는 `router-admin.internal.example.com` 을 사용한다.
|
|
||||||
- admin host는 사내망, VPN, 프라이빗 DNS 같은 내부 경로에서만 해석되거나 접근된다.
|
|
||||||
- 앱 이미지는 역할별로 분리되어 있다고 가정한다.
|
|
||||||
- `server` 이미지: Express 서버
|
|
||||||
- `admin-client` 이미지: 관리자 프론트 정적 파일을 nginx로 서빙하는 이미지
|
|
||||||
|
|
||||||
## Recommended Topology
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Internet
|
Internet
|
||||||
-> Traefik
|
-> Traefik
|
||||||
-> public IngressRoute
|
-> public IngressRoute
|
||||||
-> router-server Service:3000
|
-> router-app Service:3000
|
||||||
|
|
||||||
Internal network / VPN
|
Internal network / VPN
|
||||||
-> Traefik
|
-> Traefik
|
||||||
-> admin frontend IngressRoute + ipAllowList middleware
|
-> admin IngressRoute + ipAllowList middleware
|
||||||
-> admin-client Service:80
|
-> router-app Service:3000
|
||||||
|
|
||||||
Admin frontend
|
|
||||||
-> same-origin /admin/**
|
|
||||||
-> admin api IngressRoute + ipAllowList middleware
|
|
||||||
-> router-server Service:3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
핵심은 public/admin을 서로 다른 host로 분리하고, admin 쪽만 Traefik middleware로 한 번 더 제한하는 것이다.
|
## App Deployment
|
||||||
|
|
||||||
## Environment And Secrets
|
|
||||||
|
|
||||||
아래 예시는 서버에 필요한 주요 ENV만 담는다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: router-server-secret
|
|
||||||
namespace: llm-router
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
ADMIN_PASSWORD_HASH: "scrypt$..."
|
|
||||||
ADMIN_SESSION_SECRET: "replace-with-long-random-secret"
|
|
||||||
OIDC_CLIENT_SECRET: ""
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: router-server-config
|
|
||||||
namespace: llm-router
|
|
||||||
data:
|
|
||||||
NODE_ENV: "production"
|
|
||||||
SERVER_PORT: "3000"
|
|
||||||
DB_DIR: "/data"
|
|
||||||
TZ: "Asia/Seoul"
|
|
||||||
CORS_ORIGINS: "https://router-admin.internal.example.com"
|
|
||||||
ADMIN_AUTH_MODE: "both"
|
|
||||||
ADMIN_USERNAME: "admin"
|
|
||||||
ADMIN_SESSION_TTL_HOURS: "12"
|
|
||||||
ADMIN_API_TOKEN_TTL_DAYS: "30"
|
|
||||||
ADMIN_TRUSTED_PROXY_IPS: "10.0.0.0/8,192.168.0.0/16"
|
|
||||||
OIDC_ISSUER_URL: ""
|
|
||||||
OIDC_CLIENT_ID: ""
|
|
||||||
OIDC_REDIRECT_URI: "https://router-admin.internal.example.com/admin/auth/oidc/callback"
|
|
||||||
OIDC_ALLOWED_EMAILS: "admin1@example.com,admin2@example.com"
|
|
||||||
OIDC_SCOPES: "openid profile email"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server Deployment
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: router-server
|
name: router-app
|
||||||
namespace: llm-router
|
namespace: llm-router
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: router-server
|
app: router-app
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: router-server
|
app: router-app
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: app
|
||||||
image: ghcr.io/example/kyush-llm-router-server:latest
|
image: ghcr.io/example/kyush-llm-router:latest
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: router-server-config
|
name: router-app-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: router-server-secret
|
name: router-app-secret
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: router-data
|
- name: router-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|
@ -120,14 +59,10 @@ spec:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
port: 3000
|
port: 3000
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
port: 3000
|
port: 3000
|
||||||
initialDelaySeconds: 15
|
|
||||||
periodSeconds: 20
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: router-data
|
- name: router-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|
@ -136,82 +71,18 @@ spec:
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: router-server
|
name: router-app
|
||||||
namespace: llm-router
|
namespace: llm-router
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: router-server
|
app: router-app
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
port: 3000
|
port: 3000
|
||||||
targetPort: 3000
|
targetPort: 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
## Admin IP Allowlist Middleware
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: router-data
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: 10Gi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Admin Frontend Deployment
|
|
||||||
|
|
||||||
정적 파일 서빙이라면 nginx를 사용해도 충분하다. 여기서는 빌드된 관리자 프론트를 nginx가 서빙하는 전용 이미지를 사용한다고 가정한다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: admin-client
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: admin-client
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: admin-client
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: admin-client
|
|
||||||
image: ghcr.io/example/kyush-llm-router-admin:latest
|
|
||||||
ports:
|
|
||||||
- containerPort: 80
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 80
|
|
||||||
initialDelaySeconds: 5
|
|
||||||
periodSeconds: 10
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: admin-client
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: admin-client
|
|
||||||
ports:
|
|
||||||
- name: http
|
|
||||||
port: 80
|
|
||||||
targetPort: 80
|
|
||||||
```
|
|
||||||
|
|
||||||
중요한 점은 nginx를 ingress 대신 내부 정적 파일 서버로만 쓰고, 외부 라우팅과 접근 제어는 Traefik이 담당하게 두는 것이다.
|
|
||||||
|
|
||||||
## Traefik Middleware
|
|
||||||
|
|
||||||
관리자용 host에만 내부망 IP allowlist를 적용한다.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
|
@ -225,17 +96,10 @@ spec:
|
||||||
- 10.0.0.0/8
|
- 10.0.0.0/8
|
||||||
- 172.16.0.0/12
|
- 172.16.0.0/12
|
||||||
- 192.168.0.0/16
|
- 192.168.0.0/16
|
||||||
- 100.64.0.0/10
|
|
||||||
ipStrategy:
|
|
||||||
depth: 1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`ipStrategy.depth` 는 Traefik 앞단에 L4/L7 프록시가 하나 더 있는 환경에서만 맞춰야 한다. 직접 노출된 Traefik이라면 생략하는 편이 안전하다.
|
|
||||||
|
|
||||||
## Public IngressRoute
|
## Public IngressRoute
|
||||||
|
|
||||||
외부 공개는 `/v1` 과 `/health` 만 노출한다.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: IngressRoute
|
kind: IngressRoute
|
||||||
|
|
@ -249,139 +113,33 @@ spec:
|
||||||
- match: Host(`router.example.com`) && (PathPrefix(`/v1`) || Path(`/health`))
|
- match: Host(`router.example.com`) && (PathPrefix(`/v1`) || Path(`/health`))
|
||||||
kind: Rule
|
kind: Rule
|
||||||
services:
|
services:
|
||||||
- name: router-server
|
- name: router-app
|
||||||
port: 3000
|
port: 3000
|
||||||
tls:
|
tls:
|
||||||
secretName: router-example-com-tls
|
secretName: router-example-com-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
이 라우트에는 `/admin` 이나 관리자 프론트 경로를 넣지 않는다.
|
## Admin IngressRoute
|
||||||
|
|
||||||
## Admin Frontend IngressRoute
|
|
||||||
|
|
||||||
관리자 프론트는 내부 전용 host로 따로 분리한다.
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: IngressRoute
|
kind: IngressRoute
|
||||||
metadata:
|
metadata:
|
||||||
name: router-admin-frontend
|
name: router-admin
|
||||||
namespace: llm-router
|
namespace: llm-router
|
||||||
spec:
|
spec:
|
||||||
entryPoints:
|
entryPoints:
|
||||||
- websecure
|
- websecure
|
||||||
routes:
|
routes:
|
||||||
- match: Host(`router-admin.internal.example.com`) && Path(`/`)
|
- match: Host(`router-admin.internal.example.com`) && (PathPrefix(`/admin`) || PathPrefix(`/dashboard`))
|
||||||
kind: Rule
|
kind: Rule
|
||||||
middlewares:
|
middlewares:
|
||||||
- name: admin-ip-allowlist
|
- name: admin-ip-allowlist
|
||||||
services:
|
services:
|
||||||
- name: admin-client
|
- name: router-app
|
||||||
port: 80
|
|
||||||
- match: Host(`router-admin.internal.example.com`) && PathPrefix(`/assets`)
|
|
||||||
kind: Rule
|
|
||||||
middlewares:
|
|
||||||
- name: admin-ip-allowlist
|
|
||||||
services:
|
|
||||||
- name: admin-client
|
|
||||||
port: 80
|
|
||||||
tls:
|
|
||||||
secretName: router-admin-internal-tls
|
|
||||||
```
|
|
||||||
|
|
||||||
단일 페이지 앱 경로 재작성은 admin-client 이미지 내부 nginx 설정에서 처리하는 편이 단순하다.
|
|
||||||
|
|
||||||
## Admin API IngressRoute
|
|
||||||
|
|
||||||
관리자 프론트가 same-origin `/admin/**` 를 직접 호출할 수 있도록 같은 host 아래에서 관리자 API를 서버로 보낸다.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: traefik.io/v1alpha1
|
|
||||||
kind: IngressRoute
|
|
||||||
metadata:
|
|
||||||
name: router-admin-api
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
entryPoints:
|
|
||||||
- websecure
|
|
||||||
routes:
|
|
||||||
- match: Host(`router-admin.internal.example.com`) && PathPrefix(`/admin`)
|
|
||||||
kind: Rule
|
|
||||||
middlewares:
|
|
||||||
- name: admin-ip-allowlist
|
|
||||||
services:
|
|
||||||
- name: router-server
|
|
||||||
port: 3000
|
port: 3000
|
||||||
tls:
|
tls:
|
||||||
secretName: router-admin-internal-tls
|
secretName: router-admin-internal-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
이 방식이면 prefix 제거용 middleware가 필요 없고, 브라우저 요청:
|
이 구조는 관리자 API와 관리자 UI를 같은 origin에 두면서도, Traefik이 내부망 전용 접근 제어를 담당하게 만든다.
|
||||||
|
|
||||||
```text
|
|
||||||
GET https://router-admin.internal.example.com/admin/auth/session
|
|
||||||
```
|
|
||||||
|
|
||||||
은 서버에 그대로 아래처럼 전달된다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /admin/auth/session
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional Hardening
|
|
||||||
|
|
||||||
- `router-server` Service를 `ClusterIP`로만 두고 외부 노출은 Traefik만 담당하게 한다.
|
|
||||||
- `NetworkPolicy`로 Traefik namespace에서만 `router-server` 와 `admin-client` 에 접근 가능하게 제한한다.
|
|
||||||
- 서버의 `ADMIN_TRUSTED_PROXY_IPS` 에 Traefik Pod CIDR 또는 Service CIDR 범위를 넣어 추가 방어선을 둔다.
|
|
||||||
- admin host는 공인 DNS에 올리지 않고 split-horizon DNS 또는 내부 DNS로만 배포한다.
|
|
||||||
- `/health` 와 `/admin/health` 의 공개 범위를 운영 정책에 맞게 다시 점검한다.
|
|
||||||
|
|
||||||
## Minimal NetworkPolicy Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: NetworkPolicy
|
|
||||||
metadata:
|
|
||||||
name: router-server-ingress
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app: router-server
|
|
||||||
policyTypes:
|
|
||||||
- Ingress
|
|
||||||
ingress:
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: traefik
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 3000
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: NetworkPolicy
|
|
||||||
metadata:
|
|
||||||
name: admin-client-ingress
|
|
||||||
namespace: llm-router
|
|
||||||
spec:
|
|
||||||
podSelector:
|
|
||||||
matchLabels:
|
|
||||||
app: admin-client
|
|
||||||
policyTypes:
|
|
||||||
- Ingress
|
|
||||||
ingress:
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: traefik
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- 이 문서는 예시이며, 실제 이미지 이름, TLS secret 이름, CIDR 범위, namespace 이름은 환경에 맞게 바꿔야 한다.
|
|
||||||
- Traefik CRD 버전에 따라 구버전 클러스터는 `traefik.containo.us/v1alpha1` 를 사용할 수 있다. 현재 예시는 `traefik.io/v1alpha1` 기준이다.
|
|
||||||
- IP allowlist는 강한 방어선이지만, 관리자 인증 자체를 대체하지 않는다. 현재 서버의 관리자 세션/OIDC/토큰 인증은 그대로 유지해야 한다.
|
|
||||||
|
|
|
||||||
38
docs/oidc.md
38
docs/oidc.md
|
|
@ -1,22 +1,21 @@
|
||||||
# OpenID Connect Setup
|
# OpenID Connect Setup
|
||||||
|
|
||||||
관리자 인증은 generic OpenID Connect discovery 기반으로 동작한다.
|
관리자 OIDC 인증은 generic OpenID Connect discovery와 authorization code flow를 사용한다.
|
||||||
특정 공급자 전용 분기 없이 issuer metadata 와 authorization code flow 를 사용한다.
|
|
||||||
|
|
||||||
## Required Environment Variables
|
## Required Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `ADMIN_AUTH_MODE` | `oidc` 또는 `both` |
|
| `ADMIN_AUTH_MODE` | `oidc` 또는 `both` |
|
||||||
| `ADMIN_SESSION_SECRET` | state, nonce, session 보호용 비밀값 |
|
| `ADMIN_SESSION_SECRET` | state/session 보호용 비밀값 |
|
||||||
| `OIDC_ISSUER_URL` | issuer URL |
|
| `OIDC_ISSUER_URL` | issuer URL |
|
||||||
| `OIDC_CLIENT_ID` | client id |
|
| `OIDC_CLIENT_ID` | client id |
|
||||||
| `OIDC_CLIENT_SECRET` | client secret |
|
| `OIDC_CLIENT_SECRET` | client secret |
|
||||||
| `OIDC_REDIRECT_URI` | callback URL |
|
| `OIDC_REDIRECT_URI` | callback URL |
|
||||||
| `OIDC_ALLOWED_EMAILS` | 관리자 허용 이메일 목록 |
|
| `OIDC_ALLOWED_EMAILS` | 관리자 allowlist |
|
||||||
| `OIDC_SCOPES` | 기본값 `openid profile email` |
|
| `OIDC_SCOPES` | 기본값 `openid profile email` |
|
||||||
|
|
||||||
## Minimal Example
|
## Local Example
|
||||||
|
|
||||||
```env
|
```env
|
||||||
ADMIN_AUTH_MODE=both
|
ADMIN_AUTH_MODE=both
|
||||||
|
|
@ -24,7 +23,7 @@ ADMIN_SESSION_SECRET=replace-with-long-random-secret
|
||||||
OIDC_ISSUER_URL=https://your-issuer.example.com
|
OIDC_ISSUER_URL=https://your-issuer.example.com
|
||||||
OIDC_CLIENT_ID=your-client-id
|
OIDC_CLIENT_ID=your-client-id
|
||||||
OIDC_CLIENT_SECRET=your-client-secret
|
OIDC_CLIENT_SECRET=your-client-secret
|
||||||
OIDC_REDIRECT_URI=http://localhost:3002/admin/auth/oidc/callback
|
OIDC_REDIRECT_URI=http://localhost:3000/admin/auth/oidc/callback
|
||||||
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
||||||
OIDC_SCOPES=openid profile email
|
OIDC_SCOPES=openid profile email
|
||||||
```
|
```
|
||||||
|
|
@ -37,29 +36,16 @@ ADMIN_SESSION_SECRET=replace-with-long-random-secret
|
||||||
OIDC_ISSUER_URL=https://auth.example.com/realms/main
|
OIDC_ISSUER_URL=https://auth.example.com/realms/main
|
||||||
OIDC_CLIENT_ID=kyush-router-admin
|
OIDC_CLIENT_ID=kyush-router-admin
|
||||||
OIDC_CLIENT_SECRET=replace-with-client-secret
|
OIDC_CLIENT_SECRET=replace-with-client-secret
|
||||||
OIDC_REDIRECT_URI=https://admin.internal.example.com/admin/auth/oidc/callback
|
OIDC_REDIRECT_URI=https://router-admin.internal.example.com/admin/auth/oidc/callback
|
||||||
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
OIDC_ALLOWED_EMAILS=admin1@example.com,admin2@example.com
|
||||||
OIDC_SCOPES=openid profile email
|
OIDC_SCOPES=openid profile email
|
||||||
```
|
```
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
1. 브라우저가 `GET /admin/auth/oidc/start` 로 이동한다.
|
1. 브라우저가 `GET /admin/auth/oidc/start` 로 이동한다
|
||||||
2. 서버는 discovery metadata 를 읽고 authorization endpoint 로 redirect 한다.
|
2. 서버가 공급자 authorization endpoint 로 redirect 한다
|
||||||
3. IdP 로그인 후 `OIDC_REDIRECT_URI` 로 callback 된다.
|
3. 공급자가 `OIDC_REDIRECT_URI` 로 다시 redirect 한다
|
||||||
4. 서버는 code exchange 를 수행하고 ID token / userinfo 에서 principal 을 구성한다.
|
4. 서버가 code exchange 를 수행하고 사용자를 검증한다
|
||||||
5. 이메일이 `OIDC_ALLOWED_EMAILS` 에 포함되면 관리자 세션을 생성한다.
|
5. 이메일이 allowlist 에 있으면 관리자 세션을 생성한다
|
||||||
6. 이후 브라우저는 세션 쿠키로 `/admin/**` 를 호출한다.
|
6. 브라우저는 `/dashboard` 로 진입한다
|
||||||
|
|
||||||
## Allowlist Policy
|
|
||||||
|
|
||||||
- 관리자 승인은 이메일 allowlist 로 제한한다.
|
|
||||||
- allowlist 에 없는 계정은 인증에 성공해도 관리자 권한을 얻지 못한다.
|
|
||||||
- 이메일 비교는 운영 중 표기 흔들림을 막기 위해 소문자 정규화를 전제로 하는 편이 좋다.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `OIDC_REDIRECT_URI` 는 실제 브라우저가 접근하는 관리자 origin 기준이어야 한다.
|
|
||||||
- 관리자 프론트가 same-origin `/admin/**` 를 사용하므로 callback 도 같은 origin 아래 두는 구성이 가장 단순하다.
|
|
||||||
- `OIDC_ALLOWED_EMAILS` 가 비어 있으면 운영 환경에서는 사실상 관리자 승인이 열려버릴 수 있으므로 명시적으로 설정하는 편이 안전하다.
|
|
||||||
- OIDC 는 관리자 인증 수단일 뿐이며, 내부망 접근 제어와 세션/토큰 정책을 대체하지 않는다.
|
|
||||||
|
|
|
||||||
126
docs/server.md
126
docs/server.md
|
|
@ -6,99 +6,59 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
server/src/
|
server/src/
|
||||||
index.ts # Express 엔트리포인트(CORS, 라우터 마운트, health 핸들러, 관리자 인증 적용)
|
index.ts # Express 엔트리포인트, API/admin 라우트 마운트, dashboard 정적 파일 서빙
|
||||||
config/
|
config/
|
||||||
db-paths.ts # DB_DIR 기준 파일 경로 계산
|
admin-auth.ts # 관리자 인증 ENV 파싱
|
||||||
database.ts # Core SQLite 연결 및 스키마 초기화
|
database.ts # core DB 초기화
|
||||||
analytics-db.ts # Analytics SQLite 연결 및 스키마 초기화
|
analytics-db.ts # analytics DB 초기화
|
||||||
request-logs-db.ts # 월별 request_logs SQLite 연결 및 스키마 초기화
|
request-logs-db.ts # 월별 request log DB 초기화
|
||||||
admin-auth.ts # 관리자 인증 ENV 파싱, auth mode / OIDC / TTL 설정
|
|
||||||
models/
|
|
||||||
User.ts # 사용자 CRUD (create, findById, findByApiKey, update, delete, regenerateApiKey)
|
|
||||||
Backend.ts # 백엔드 CRUD (create, findById, findAll, update, delete)
|
|
||||||
Permission.ts # 권한 관리 (user-backend 매핑)
|
|
||||||
Script.ts # 스크립트 CRUD (타입별 필터링, 활성화/비활성화)
|
|
||||||
AdminSession.ts # 관리자 세션 저장/조회/만료 처리
|
|
||||||
AdminApiToken.ts # 관리자 API 토큰 발급/조회/폐기
|
|
||||||
routes/
|
routes/
|
||||||
auth.ts # Bearer 토큰 인증 미들웨어 (사용자 API 키 검증, 권한 로드)
|
api.ts # /v1 핸들러
|
||||||
api.ts # OpenAI 호환 프록시 핸들러 (/v1/chat/completions, /v1/models)
|
admin-auth.ts # /admin/auth 핸들러
|
||||||
admin-auth.ts # 관리자 로그인, 세션, OIDC, 관리자 토큰 API
|
admin.ts # /admin CRUD 핸들러
|
||||||
admin.ts # Admin CRUD 핸들러 (users, backends, permissions, /admin/health, /admin/scripts 마운트)
|
analytics.ts # /admin/analytics 핸들러
|
||||||
scripts.ts # Script 관리/테스트 핸들러
|
|
||||||
analytics.ts # Analytics 조회 핸들러
|
|
||||||
services/
|
services/
|
||||||
RouterService.ts # 활성 백엔드 선택, HTTP 요청 포워딩, header/body 정규화
|
RouterService.ts # 백엔드 선택 및 포워딩
|
||||||
AnalyticsService.ts # 일별 사용량 메트릭 집계 + request_logs 조회
|
AnalyticsService.ts # 사용량 집계 및 요청 로그 조회
|
||||||
RequestLogService.ts # 월별 request_logs 기록/조회
|
ScriptEngine.ts # 스크립트 오케스트레이션
|
||||||
ScriptEngine.ts # 스크립트 체인 오케스트레이션 (onRequest/onResponse 적용)
|
|
||||||
ScriptExecutor.ts # isolated-vm 기반 스크립트 컴파일/실행 (5s timeout, 50MB memory)
|
|
||||||
utils/
|
utils/
|
||||||
apiKey.ts # API 키 생성 (sk-{timestamp}-{random}, crypto.randomBytes)
|
adminAuth.ts # 관리자 세션/토큰 인증 + CSRF 검사
|
||||||
adminAuth.ts # 관리자 principal 추출, 세션/토큰 인증 미들웨어, CSRF 검증
|
adminSecurity.ts # 비밀번호/토큰 보안 유틸
|
||||||
adminSecurity.ts # 비밀번호 hash 검증, 토큰 생성/hash, OIDC state/nonce 처리
|
logger.ts # 콘솔 로깅
|
||||||
logger.ts # 컬러 콘솔 로거
|
|
||||||
time.ts # TZ 기준 날짜/월 계산, UTC timestamp 생성
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Request Flow
|
## Runtime Behavior
|
||||||
|
|
||||||
```text
|
- `/v1/**` 는 공개 라우터 API를 유지한다
|
||||||
Client -> auth.ts (사용자 API 키 검증, 권한 로드)
|
- `/health` 는 공개 상태 확인 엔드포인트를 유지한다
|
||||||
-> RouterService.selectBackend (허용된 활성 백엔드 중 1개 선택)
|
- `/admin/**` 는 관리자 API 표면을 유지한다
|
||||||
-> ScriptEngine.applyOnRequestScripts (요청 변조)
|
- `/dashboard` 와 `/dashboard/**` 는 빌드된 관리자 SPA를 서빙한다
|
||||||
-> RouterService.forwardRequest (백엔드로 프록시)
|
- 빌드된 `client/dist` 가 있으면 관리자 UI를 함께 제공하고, 없으면 API 전용 모드처럼 동작한다
|
||||||
-> ScriptEngine.applyOnResponseScripts (응답 컨텍스트 후처리 결과)
|
|
||||||
-> AnalyticsService.logRequest (집계 + 월별 request_logs 기록)
|
|
||||||
-> Response
|
|
||||||
```
|
|
||||||
|
|
||||||
참고:
|
라우트 우선순위는 다음과 같다.
|
||||||
- 라우터 마운트는 `server/src/index.ts` 에서 직접 수행한다.
|
|
||||||
- `/admin/**` 는 별도 관리자 인증 레이어를 거친 뒤 각 CRUD/analytics 라우트로 들어간다.
|
|
||||||
- `/admin/auth/*` 만 관리자 영역 내 공개 예외 엔드포인트다.
|
|
||||||
- `onResponse` 훅은 실행되지만 현재 구현에서는 반환값을 최종 HTTP 응답에 다시 반영하지 않는다.
|
|
||||||
|
|
||||||
## Time & Storage
|
1. `/admin/auth`
|
||||||
|
2. `/admin/analytics`
|
||||||
|
3. `/admin`
|
||||||
|
4. `/v1`
|
||||||
|
5. `/health`
|
||||||
|
6. `/dashboard` 정적 파일 + SPA fallback
|
||||||
|
7. `404`
|
||||||
|
|
||||||
- DB 루트는 `DB_DIR`로 설정한다. 파일은 `core.db`, `analytics.db`, `request_logs/request_logs_YYYY-MM.db` 구조로 생성된다.
|
## Storage
|
||||||
- `core.db`에는 관리자 세션과 관리자 API 토큰을 위한 `admin_sessions`, `admin_api_tokens` 테이블도 생성된다.
|
|
||||||
- 일/월 경계 계산은 `TZ` 기준으로 수행된다.
|
|
||||||
- 저장되는 timestamp 문자열은 UTC ISO 형식으로 통일된다.
|
|
||||||
|
|
||||||
## Admin Auth Notes
|
- `DB_DIR` 에 `core.db`, `analytics.db`, `request_logs/request_logs_YYYY-MM.db` 가 저장된다
|
||||||
|
- `core.db` 에는 `admin_sessions`, `admin_api_tokens` 도 함께 저장된다
|
||||||
|
- 시간 경계 계산은 `TZ` 기준이다
|
||||||
|
|
||||||
- 관리자 브라우저 인증은 서버사이드 세션 + `HttpOnly` 쿠키를 사용한다.
|
## Deployment Notes
|
||||||
- 자동화용 관리자 인증은 Bearer 관리자 API 토큰을 사용한다.
|
|
||||||
- 세션 기반 `POST/PUT/DELETE` 요청에는 CSRF 검사가 적용된다.
|
|
||||||
- OIDC는 generic discovery 방식으로 동작하며, 허용 이메일은 `OIDC_ALLOWED_EMAILS`로 제한한다.
|
|
||||||
- 권장 배포는 public/admin 게이트웨이 분리 구조다. public 쪽은 `/v1/**`, `/health`만 노출하고 admin 쪽은 관리자 프론트와 `/admin/**`를 same-origin으로 제공한다.
|
|
||||||
|
|
||||||
## Dependencies
|
- 권장 런타임은 단일 OCI 이미지다
|
||||||
|
- 이 이미지는 아래를 함께 포함한다
|
||||||
| Package | Purpose |
|
- 서버 런타임
|
||||||
|---------|---------|
|
- 빌드된 관리자 대시보드 자산
|
||||||
| express@5 | 웹 프레임워크 |
|
- database 스키마 파일
|
||||||
| better-sqlite3 | SQLite 드라이버 |
|
- `docker-compose` 에서는 API와 관리자 UI를 단일 포트로 노출할 수 있다
|
||||||
| isolated-vm | 스크립트 샌드박스 실행 |
|
- Kubernetes에서는 Traefik이 아래처럼 path 기준으로 나눈다
|
||||||
| zod | 입력 검증 |
|
- 공개: `/v1/**`, `/health`
|
||||||
| cors | CORS 미들웨어 |
|
- 내부 전용: `/admin/**`, `/dashboard`, `/dashboard/**`
|
||||||
| dotenv | 환경변수 로딩 |
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
통합 테스트: `server/tests/integration/`
|
|
||||||
- `api.test.ts` - 인증, 에러, 프록시 핸들러
|
|
||||||
- `admin-auth.test.ts` - 관리자 로그인, 세션, CSRF, 관리자 토큰
|
|
||||||
- `admin.test.ts` - Admin CRUD
|
|
||||||
- `routing.test.ts` - 백엔드 선택, 요청 포워딩
|
|
||||||
- `scripts.test.ts` - 스크립트 생성, 실행, 검증
|
|
||||||
|
|
||||||
테스트 유틸: `server/tests/utils/` (`testApp.ts`, `mockBackend.ts`, `adminClient.ts`)
|
|
||||||
|
|
||||||
벤치마크: `server/benchmarks/` (`index.ts`, `runner.ts`, `scenarios.ts`, `report.ts`, `stats.ts`)
|
|
||||||
|
|
||||||
사용 문서:
|
|
||||||
- [docs/benchmarks.md](./benchmarks.md) - benchmark CLI usage, modes, output, caveats
|
|
||||||
- [docs/admin-auth.md](./admin-auth.md) - 관리자 인증, 세션, CSRF, 관리자 토큰
|
|
||||||
- [docs/oidc.md](./oidc.md) - OpenID Connect 설정과 allowlist 정책
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "LLM Router Server",
|
"description": "LLM Router Server",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc && node scripts/copy-schemas.mjs",
|
||||||
"start": "node dist/server/src/index.js",
|
"start": "node dist/server/src/index.js",
|
||||||
"dev": "tsx watch src",
|
"dev": "tsx watch src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
16
server/scripts/copy-schemas.mjs
Normal file
16
server/scripts/copy-schemas.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const serverRoot = path.resolve(__dirname, '..');
|
||||||
|
const repoRoot = path.resolve(serverRoot, '..');
|
||||||
|
const sourceDir = path.join(repoRoot, 'database');
|
||||||
|
const targetDir = path.join(serverRoot, 'dist', 'database');
|
||||||
|
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const fileName of ['schema.sql', 'analytics-schema.sql', 'request-logs-schema.sql']) {
|
||||||
|
fs.copyFileSync(path.join(sourceDir, fileName), path.join(targetDir, fileName));
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express, { Application } from 'express';
|
import express, { Application } from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import adminRoutes from './routes/admin';
|
import adminRoutes from './routes/admin';
|
||||||
|
|
@ -11,16 +12,30 @@ import { requireAdminAccess, requireSessionCsrf } from './utils/adminAuth';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from './utils/logger';
|
||||||
import { getUtcTimestamp } from './utils/time';
|
import { getUtcTimestamp } from './utils/time';
|
||||||
|
|
||||||
|
const envPathCandidates = [
|
||||||
|
path.resolve(__dirname, '..', '..', '.env'),
|
||||||
|
path.resolve(__dirname, '..', '..', '..', '..', '.env'),
|
||||||
|
path.resolve(process.cwd(), '.env'),
|
||||||
|
path.resolve(process.cwd(), '..', '.env'),
|
||||||
|
];
|
||||||
|
const resolvedEnvPath = envPathCandidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
|
path: resolvedEnvPath,
|
||||||
quiet: true,
|
quiet: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createServer(): Application {
|
export function createServer(): Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
const adminDistCandidates = [
|
||||||
|
path.resolve(__dirname, '..', '..', '..', 'client', 'dist'),
|
||||||
|
path.resolve(__dirname, '..', '..', '..', '..', 'client', 'dist'),
|
||||||
|
];
|
||||||
|
const adminDistPath = adminDistCandidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
const corsOrigins = process.env.CORS_ORIGINS
|
const corsOrigins = process.env.CORS_ORIGINS
|
||||||
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
|
? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
|
||||||
: ['http://localhost:5173', 'http://localhost:3001', 'http://localhost:3002', 'http://127.0.0.1:3002'];
|
: ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3002', 'http://127.0.0.1:3002'];
|
||||||
|
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: corsOrigins,
|
origin: corsOrigins,
|
||||||
|
|
@ -37,6 +52,18 @@ export function createServer(): Application {
|
||||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (adminDistPath) {
|
||||||
|
app.use('/dashboard', express.static(adminDistPath, { index: false, fallthrough: true }));
|
||||||
|
app.get(/^\/dashboard(?:\/.*)?$/, (req, res, next) => {
|
||||||
|
if (path.extname(req.path)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.sendFile(path.join(adminDistPath, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: 'Not found' });
|
res.status(404).json({ error: 'Not found' });
|
||||||
});
|
});
|
||||||
|
|
@ -52,6 +79,7 @@ if (require.main === module) {
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
logger.info(`Server running on port ${PORT}`);
|
logger.info(`Server running on port ${PORT}`);
|
||||||
logger.info(`Admin API: http://localhost:${PORT}/admin`);
|
logger.info(`Admin API: http://localhost:${PORT}/admin`);
|
||||||
|
logger.info(`Admin UI: http://localhost:${PORT}/dashboard`);
|
||||||
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
|
logger.info(`OpenAI API: http://localhost:${PORT}/v1`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,23 @@ const router: Router = Router();
|
||||||
const oidcStateStore = new Map<string, { next: string; expiresAt: number }>();
|
const oidcStateStore = new Map<string, { next: string; expiresAt: number }>();
|
||||||
|
|
||||||
function isSafeNextPath(value?: string): string {
|
function isSafeNextPath(value?: string): string {
|
||||||
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/api/')) {
|
if (!value || value === '/') {
|
||||||
return '/';
|
return '/dashboard';
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
|
if (!value.startsWith('/') || value.startsWith('//')) {
|
||||||
|
return '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('/admin/') || value === '/admin') {
|
||||||
|
return '/dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === '/dashboard' || value.startsWith('/dashboard/')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionResponse(req: AdminRequest): AdminSessionResponse {
|
function buildSessionResponse(req: AdminRequest): AdminSessionResponse {
|
||||||
|
|
@ -120,7 +133,7 @@ router.get('/oidc/start', async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = generateOpaqueToken('oidc_state');
|
const state = generateOpaqueToken('oidc_state');
|
||||||
const next = isSafeNextPath(typeof req.query.next === 'string' ? req.query.next : '/');
|
const next = isSafeNextPath(typeof req.query.next === 'string' ? req.query.next : '/dashboard');
|
||||||
oidcStateStore.set(state, { next, expiresAt: Date.now() + 10 * 60 * 1000 });
|
oidcStateStore.set(state, { next, expiresAt: Date.now() + 10 * 60 * 1000 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue