Добавить приоритетную очередь, аутентификацию и администрирование

- Приоритетная очередь для контроля параллельных запросов
- Аутентификация по API-ключу из URL (/auth/<key>/v1/...)
- Роли пользователей с белым списком моделей и ограничением контекста (num_ctx)
- Sliding window rate limiting
- Admin API для горячей перезагрузки users.json без перезапуска прокси
- Graceful shutdown с таймаутом завершения активных запросов
- Маскировка API-ключа в логах
- Подробная инструкция по установке для Windows и Linux (SETUP_WIN_SERVER.md)
This commit is contained in:
2026-03-28 15:15:51 +03:00
parent a647921841
commit 5118914823
18 changed files with 1488 additions and 47 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
.env
.vscode/
.DS_Store
.claude/
# конфиг пользователей (содержит API-ключи)
users.json
# собранные бинарники
*.exe

482
SETUP_WIN_SERVER.md Normal file
View File

@@ -0,0 +1,482 @@
# Настройка Ollama Proxy
---
## Архитектура
```
Codex (клиентский ноутбук)
↓ HTTP-запрос с API-ключом в URL
http://<IP_СЕРВЕРА>:8080/auth/<ключ>/v1
Go Proxy (сервер, порт 8080) ← единственная точка входа снаружи
│ ├── извлечение API-ключа из URL
│ ├── аутентификация пользователя
│ ├── проверка доступа к модели
│ ├── rate limiting
│ └── приоритетная очередь (VIP первый)
Ollama (сервер, localhost:11434) ← недоступна снаружи напрямую
```
---
## Часть 1: Настройка сервера
### 1. Установить Ollama
**Windows:**
Скачать установщик с [ollama.com](https://ollama.com) и установить.
**Linux:**
```bash
curl -fsSL https://ollama.com/install.sh | sh
```
После установки скачать нужные модели (полный список смотрите на сайте ollama):
```bash
ollama pull qwen2.5:1.5b
ollama pull qwen3:8b
```
Проверить что модели скачались:
```bash
ollama list
```
---
### 2. Настроить переменные Ollama
По умолчанию Ollama слушает `0.0.0.0` — это значит, что к ней можно подключиться напрямую из сети, минуя прокси. После настройки прокси Ollama должна слушать только `localhost`.
**Windows** (PowerShell от имени администратора):
```powershell
# Ollama слушает только localhost. Внешние подключения идут через прокси
setx OLLAMA_HOST "127.0.0.1" /M
# Модель остаётся в памяти 30 минут после последнего запроса.
# Без этого каждый запрос ждёт повторной загрузки модели (15-30 сек)
setx OLLAMA_KEEP_ALIVE "30m" /M
# Количество параллельных запросов.
# ВАЖНО: должно совпадать с MAX_PARALLEL в .env прокси
setx OLLAMA_NUM_PARALLEL "2" /M
# Ускорение инференса на GPU (если поддерживается)
setx OLLAMA_FLASH_ATTENTION "1" /M
```
После `setx /M` закрой и открой PowerShell заново — иначе переменные не подхватятся.
Запустить Ollama в новом PowerShell:
```powershell
ollama serve
```
**Linux:**
```bash
sudo systemctl edit ollama
```
Необходимо **между** строками `Anything between here and the comment below...` и `Edits below this comment will be discarded` вставить:
```ini
[Service]
Environment="OLLAMA_HOST=127.0.0.1"
Environment="OLLAMA_KEEP_ALIVE=30m"
Environment="OLLAMA_NUM_PARALLEL=2"
Environment="OLLAMA_FLASH_ATTENTION=1"
```
Сохранить: `Ctrl+O``Enter``Ctrl+X`. Применить:
```bash
sudo systemctl daemon-reload
sudo systemctl restart ollama
```
Проверить что Ollama запущена:
```bash
curl http://localhost:11434/api/tags
```
Должен вернуть JSON со списком установленных моделей.
---
### 3. Установить Go (только для сборки)
Go нужен один раз — чтобы собрать бинарник прокси.
**Windows:**
Скачать установщик `goX.XX.X.windows-amd64.msi` с [go.dev/dl](https://go.dev/dl/) и установить.
Проверить в новом PowerShell:
```powershell
go version
```
**Linux:**
```bash
wget https://go.dev/dl/go1.24.4.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version
```
---
### 4. Скопировать проект и собрать прокси
```bash
git clone <url_репозитория>
cd Proxy_for_codex
# Скачать зависимости
go mod download
# Собрать бинарник
# Windows:
go build -o proxy.exe ./src
# Linux:
go build -o service ./src
```
---
### 5. Создать конфиг пользователей
Скопировать шаблон:
```bash
# Windows:
copy users.example.json users.json
# Linux:
cp users.example.json users.json
```
Открыть `users.json` и заполнить:
```json
{
"roles": {
"vip": {
"priority": 100,
"allowed_models": ["qwen3:8b", "qwen2.5:1.5b"],
"max_context_length": 32768,
"rate_limit": { "requests": 60, "window": "1m" }
},
"regular": {
"priority": 10,
"allowed_models": ["qwen2.5:1.5b"],
"max_context_length": 8192,
"rate_limit": { "requests": 20, "window": "1m" }
}
},
"users": {
"ivanov-key-abc123": { "name": "Иванов", "role": "vip", "enabled": true },
"petrov-key-xyz789": { "name": "Петров", "role": "regular", "enabled": true }
}
}
```
**Поля конфигурации:**
| Поле | Описание |
|---|---|
| `priority` | Приоритет в очереди. Когда все слоты заняты — VIP (100) обслуживается раньше regular (10) |
| `allowed_models` | Белый список моделей. Пустой массив `[]` — доступны все модели |
| `max_context_length` | Максимальный размер контекста (`num_ctx`). Прокси урежет запрос если превышен |
| `rate_limit.requests` | Максимум запросов за окно |
| `rate_limit.window` | Длительность окна: `"1m"`, `"30s"`, `"5m"` и т.д. |
| `enabled` | `false` — пользователь заблокирован, все его запросы отклоняются с кодом 401 |
**API-ключи** — любые строки, которые придумывает администратор. Каждый сотрудник получает свой уникальный ключ. Например: `ivanov-2024-abc`, `petrov-key-001`.
---
### 6. Создать файл .env
Создать файл `.env` рядом с бинарником прокси:
```
# Адрес и порт, на котором прокси принимает входящие подключения.
# :8080 = все сетевые интерфейсы, порт 8080
LISTEN_ADDR=:8080
# URL бэкенда Ollama. Должен быть доступен только локально
OLLAMA_BACKEND=http://localhost:11434
# Количество параллельных запросов к Ollama.
# ОБЯЗАТЕЛЬНО должно совпадать с OLLAMA_NUM_PARALLEL
MAX_PARALLEL=2
# Максимальный размер очереди ожидания. При переполнении — 503
MAX_QUEUE_SIZE=100
# Путь к файлу пользователей.
# Можно указать абсолютный путь, если запускаете прокси из другой папки
USERS_CONFIG_PATH=users.json
# Ключ для admin API (чтобы менять users.json без перезапуска прокси).
# Придумайте длинную случайную строку
ADMIN_API_KEY=my-secret-admin-key
# Уровень логирования: debug, info, warn, error
LOG_LEVEL=info
```
---
### 7. Запустить прокси
**Windows:**
```powershell
cd d:\путь\к\Proxy_for_codex
.\proxy.exe
```
**Linux:**
```bash
cd ~/Proxy_for_codex
./service
```
При успешном запуске:
```
level=INFO msg="Starting Ollama Proxy version 2.0.0"
level=INFO msg="Конфигурация пользователей загружена" path=users.json active_users=2 total_users=2
level=INFO msg="Прокси запущен" addr=:8080 backend=http://localhost:11434 max_parallel=2 max_queue=100 auth_enabled=true
```
> Если `auth_enabled=false` — значит `users.json` не найден или содержит ошибку. Прокси работает, но без аутентификации.
---
### 8. Открыть порт в файрволе
**Windows** (PowerShell от имени администратора):
```powershell
New-NetFirewallRule -DisplayName "Ollama Proxy" -Direction Inbound -Port 8080 -Protocol TCP -Action Allow
```
**Linux:**
```bash
# Проверить статус файрвола
sudo ufw status
# Если Status: active — открыть порт
sudo ufw allow 8080/tcp
```
Если `Status: inactive` — файрвол выключен, ничего делать не нужно.
---
## Часть 2: Настройка клиента
### 1. Установить Codex
**Windows:**
Установить [Node.js](https://nodejs.org), затем в PowerShell:
```powershell
npm install -g @openai/codex
```
**Linux:**
```bash
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
npm install -g @openai/codex
```
---
### 2. Настроить конфиг Codex
Codex по умолчанию обращается к серверам OpenAI. Чтобы перенаправить его на наш прокси — нужно создать файл конфигурации и указать адрес прокси и API-ключ.
#### Где находится файл конфига
**Linux:**
```bash
mkdir -p ~/.codex
nano ~/.codex/config.toml
```
**Windows** — найти файл по пути:
```
C:\Users\<ваш_логин>\.codex\config.toml
```
#### Содержимое конфига
Вставить следующее, **заменив два значения** (объяснение ниже):
```toml
# Модель по умолчанию, ставьте какую хотите и какая скачана
model = "qwen2.5:1.5b"
model_provider = "ollama_proxy"
[model_providers.ollama_proxy]
name = "Ollama (прокси)"
base_url = "http://<IP_СЕРВЕРА>:8080/auth/<ключ>/v1"
wire_api = "responses"
```
**Что нужно заменить:**
**`<IP_СЕРВЕРА>`** — IP-адрес машины, где запущен прокси.
Узнать IP сервера:
- На сервере Windows: `ipconfig` → строка `IPv4-адрес`
- На сервере Linux: `hostname -I` → первое число
**`<ключ>`** — персональный API-ключ сотрудника из `users.json` на сервере. Администратор выдаёт каждому сотруднику свой ключ.
**Пример готового конфига для Иванова** с ключом `ivanov-key-abc123` и сервером `10.111.111.40`:
```toml
model = "qwen2.5:1.5b"
model_provider = "ollama_proxy"
[model_providers.ollama_proxy]
name = "Ollama (прокси)"
base_url = "http://10.111.111.40:8080/auth/ivanov-key-abc123/v1"
wire_api = "responses"
```
> Поле `model` — модель по умолчанию, которая используется при обычном запуске `codex`. Прокси проверит, разрешена ли эта модель для роли пользователя. Если нет — вернёт ошибку 403.
---
### 3. Запуск
Запустить Codex с моделью по умолчанию (из конфига):
```bash
codex
```
Запустить с другой моделью (если роль позволяет):
```bash
codex -m qwen3:8b #или любая другая модель
```
Codex отправит запрос на прокси → прокси проверит ключ → проверит, разрешена ли модель → поставит в очередь если все слоты заняты → Ollama сгенерирует ответ.
**Если что-то не работает** — попросить администратора сервера посмотреть логи прокси: там будет видно, отклонён ли запрос и по какой причине (неверный ключ, запрещённая модель, превышен rate limit).
---
## Часть 3: Администрирование
### Изменение users.json без перезапуска прокси
Можно менть положение клиентов и их роли во время работы прокси без его перезапуска. Достаточно в `users.json` изменить что хотите и тогда после любых изменений в `users.json` — добавления пользователя, смены роли, блокировки — отправить команду reload:
```bash
# Linux / Windows (в Git Bash или WSL):
curl -X POST -H "Authorization: Bearer my-secret-admin-key" http://localhost:8080/admin/reload
# Windows PowerShell:
curl -Method POST -Headers @{"Authorization"="Bearer my-secret-admin-key"} http://localhost:8080/admin/reload
```
Ответ: `{"status":"reloaded"}`
> `my-secret-admin-key` — заменить на значение `ADMIN_API_KEY` из файла `.env` на сервере.
---
### Заблокировать пользователя
В `users.json` поставить `"enabled": false` и выполнить reload:
```json
"petrov-key-xyz789": { "name": "Петров", "role": "regular", "enabled": false }
```
---
### Сменить роль пользователя
В `users.json` поменять `"role"` и выполнить reload:
```json
"petrov-key-xyz789": { "name": "Петров", "role": "vip", "enabled": true }
```
---
### Мониторинг логов
**Windows** — логи выводятся в терминал, где запущен `proxy.exe`.
**Linux (systemd):**
```bash
# Следить за логами в реальном времени
sudo journalctl -u ollama-proxy -f
# Последние 50 строк
sudo journalctl -u ollama-proxy -n 50
```
Примеры записей:
```
level=INFO msg="запрос" method=POST path=/v1/responses status=200 duration=3.456s
level=INFO msg="запрос вышел из очереди" priority=100 in_flight=1 queued=0
level=WARN msg="Запрос к запрещённой модели" user=Петров role=regular model=qwen3:8b allowed=[qwen2.5:1.5b]
level=WARN msg="Превышен rate limit" user=Петров role=regular limit=20 window=1m0s
level=WARN msg="Невалидный API-ключ" key_prefix=unknown-***
```
---
## HTTP-коды ошибок
| Код | Причина | Решение |
|---|---|---|
| 401 | Нет ключа или невалидный | Проверить `base_url` в config.toml — ключ должен быть между `/auth/` и `/v1` |
| 403 | Модель запрещена для роли | Добавить модель в `allowed_models` роли в `users.json` |
| 429 | Превышен rate limit | Подождать или увеличить лимит в роли |
| 502 | Ollama не отвечает | Проверить что Ollama запущена на сервере |
| 503 | Очередь переполнена | Увеличить `MAX_QUEUE_SIZE` или `MAX_PARALLEL` в `.env` |
---
## Важно: MAX_PARALLEL и OLLAMA_NUM_PARALLEL
Эти два значения **обязаны совпадать**:
- `MAX_PARALLEL` в `.env` прокси — сколько запросов прокси одновременно пропускает к Ollama
- `OLLAMA_NUM_PARALLEL` в переменных среды сервера — сколько запросов Ollama обрабатывает параллельно
Если значения расходятся (например, прокси=3, Ollama=2) — третий запрос попадёт во внутреннюю очередь Ollama, минуя приоритетную очередь прокси. VIP не получит преимущества.

View File

@@ -0,0 +1,69 @@
// Пакет admin реализует административный API
// для управления прокси без перезапуска.
package admin
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"backend/src/internal/auth"
)
// Handler обрабатывает административные HTTP-эндпоинты.
// Доступ защищён отдельным admin API-ключом.
type Handler struct {
logger *slog.Logger
store auth.UserStore
adminAPIKey string
}
// NewHandler создаёт обработчик административного API.
func NewHandler(logger *slog.Logger, store auth.UserStore, adminAPIKey string) *Handler {
return &Handler{
logger: logger,
store: store,
adminAPIKey: adminAPIKey,
}
}
// ReloadHandler возвращает обработчик POST /admin/reload,
// который перечитывает users.json без перезапуска прокси.
func (h *Handler) ReloadHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if h.adminAPIKey == "" {
jsonResponse(w, http.StatusForbidden, map[string]string{"error": "admin API not configured"})
return
}
header := r.Header.Get("Authorization")
key := strings.TrimPrefix(header, "Bearer ")
if key != h.adminAPIKey {
jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "invalid admin key"})
return
}
if err := h.store.Reload(); err != nil {
h.logger.Error("Ошибка перезагрузки конфигурации пользователей", "error", err)
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
h.logger.Info("Конфигурация пользователей перезагружена через admin API")
jsonResponse(w, http.StatusOK, map[string]string{"status": "reloaded"})
}
}
// jsonResponse формирует HTTP-ответ в формате JSON.
func jsonResponse(w http.ResponseWriter, code int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}

View File

@@ -0,0 +1,162 @@
// Пакет auth реализует аутентификацию по API-ключу,
// проверку доступа к моделям и ограничение размера контекста.
package auth
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"slices"
"strings"
)
type ctxKey string
// UserContextKey — ключ контекста для хранения аутентифицированного пользователя.
const UserContextKey ctxKey = "resolved_user"
// UserFromContext извлекает ResolvedUser из контекста запроса.
// Возвращает false, если пользователь не был аутентифицирован.
func UserFromContext(ctx context.Context) (ResolvedUser, bool) {
u, ok := ctx.Value(UserContextKey).(ResolvedUser)
return u, ok
}
// AuthMiddleware проверяет заголовок Authorization: Bearer <key>,
// находит пользователя в store и сохраняет ResolvedUser в контексте запроса.
func AuthMiddleware(logger *slog.Logger, store UserStoreReader, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
jsonError(w, http.StatusUnauthorized, "missing API key")
return
}
key := strings.TrimPrefix(header, "Bearer ")
if key == header {
jsonError(w, http.StatusUnauthorized, "invalid authorization format, expected Bearer <key>")
return
}
user, ok := store.LookupByKey(key)
if !ok {
logger.Warn("Невалидный API-ключ", "key_prefix", safePrefix(key))
jsonError(w, http.StatusUnauthorized, "invalid or disabled API key")
return
}
logger.Debug("Пользователь аутентифицирован", "user", user.Name, "role", user.RoleKey)
ctx := context.WithValue(r.Context(), UserContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Пути Ollama native API, где поддерживается параметр options.num_ctx.
var ollamaNativePaths = []string{
"/api/chat",
"/api/generate",
}
// Пути, содержащие поле "model" в теле запроса.
// Включает как Ollama native, так и OpenAI-совместимые эндпоинты.
var modelCheckPaths = []string{
"/api/chat",
"/api/generate",
"/v1/chat/completions",
"/v1/responses",
}
// ModelCheckMiddleware проверяет запрашиваемую модель на соответствие
// белому списку роли и ограничивает num_ctx для Ollama native API.
// Применяется только к POST-запросам на эндпоинты генерации.
func ModelCheckMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !slices.Contains(modelCheckPaths, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
user, ok := UserFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
body, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
jsonError(w, http.StatusBadRequest, "cannot read request body")
return
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
// Невалидный JSON передаётся как есть — Ollama вернёт свою ошибку.
r.Body = io.NopCloser(bytes.NewReader(body))
next.ServeHTTP(w, r)
return
}
// Проверка модели по белому списку роли.
if model, ok := payload["model"].(string); ok {
if len(user.Role.AllowedModels) > 0 && !slices.Contains(user.Role.AllowedModels, model) {
logger.Warn("Запрос к запрещённой модели",
"user", user.Name, "role", user.RoleKey,
"model", model, "allowed", user.Role.AllowedModels,
)
jsonError(w, http.StatusForbidden, "model not allowed for your role")
return
}
}
// Ограничение num_ctx только для Ollama native API.
// OpenAI-совместимые эндпоинты (/v1/...) не используют options.num_ctx.
if user.Role.MaxContextLength > 0 && slices.Contains(ollamaNativePaths, r.URL.Path) {
limitNumCtx(payload, user.Role.MaxContextLength)
}
newBody, _ := json.Marshal(payload)
r.Body = io.NopCloser(bytes.NewReader(newBody))
r.ContentLength = int64(len(newBody))
next.ServeHTTP(w, r)
})
}
// limitNumCtx ограничивает значение options.num_ctx в теле запроса.
// Если num_ctx не задан или превышает maxCtx — устанавливается maxCtx.
func limitNumCtx(payload map[string]any, maxCtx int) {
opts, _ := payload["options"].(map[string]any)
if opts == nil {
opts = make(map[string]any)
payload["options"] = opts
}
if numCtx, ok := opts["num_ctx"].(float64); ok {
if int(numCtx) > maxCtx {
opts["num_ctx"] = maxCtx
}
} else {
opts["num_ctx"] = maxCtx
}
}
// jsonError отправляет JSON-ответ с ошибкой.
func jsonError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// safePrefix возвращает первые 8 символов строки с маскировкой остатка.
// Используется для безопасного логирования API-ключей.
func safePrefix(key string) string {
if len(key) <= 8 {
return key + "***"
}
return key[:8] + "***"
}

View File

@@ -0,0 +1,49 @@
package auth
import "time"
// RateLimitConfig определяет ограничение частоты запросов для роли.
type RateLimitConfig struct {
Requests int `json:"requests"` // максимальное количество запросов за окно
Window string `json:"window"` // длительность окна: "1m", "30s" и т.д.
}
// WindowDuration преобразует строковое значение Window в time.Duration.
// При ошибке парсинга возвращает 1 минуту как значение по умолчанию.
func (r RateLimitConfig) WindowDuration() time.Duration {
d, err := time.ParseDuration(r.Window)
if err != nil {
return time.Minute
}
return d
}
// Role описывает роль пользователя с настройками доступа и приоритета.
type Role struct {
Priority int `json:"priority"` // приоритет в очереди (больше = выше)
AllowedModels []string `json:"allowed_models"` // разрешённые модели (пустой = все)
MaxContextLength int `json:"max_context_length"` // максимальный num_ctx
RateLimit RateLimitConfig `json:"rate_limit"`
}
// User описывает пользователя в конфигурационном файле.
type User struct {
Name string `json:"name"`
RoleKey string `json:"role"`
Enabled bool `json:"enabled"`
}
// UsersConfig — корневая структура файла users.json.
type UsersConfig struct {
Roles map[string]Role `json:"roles"`
Users map[string]User `json:"users"` // ключ map — API-ключ пользователя
}
// ResolvedUser — пользователь с развёрнутой ролью,
// полученный после поиска по API-ключу.
type ResolvedUser struct {
APIKey string
Name string
Role Role
RoleKey string
}

View File

@@ -0,0 +1,93 @@
package auth
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
)
// UserStoreReader предоставляет доступ к хранилищу пользователей на чтение.
type UserStoreReader interface {
LookupByKey(apiKey string) (ResolvedUser, bool)
}
// UserStore расширяет UserStoreReader возможностью горячей перезагрузки.
type UserStore interface {
UserStoreReader
Reload() error
}
// JSONFileStore загружает и хранит конфигурацию пользователей из JSON-файла.
// Поддерживает потокобезопасный доступ через sync.RWMutex
// и горячую перезагрузку без остановки сервера.
type JSONFileStore struct {
path string
logger *slog.Logger
mu sync.RWMutex
users map[string]ResolvedUser // ключ map — API-ключ
}
// NewJSONFileStore создаёт хранилище и загружает данные из файла.
// Возвращает ошибку, если файл не найден или содержит невалидную конфигурацию.
func NewJSONFileStore(path string, logger *slog.Logger) (*JSONFileStore, error) {
s := &JSONFileStore{
path: path,
logger: logger,
}
if err := s.Reload(); err != nil {
return nil, err
}
return s, nil
}
// Reload перечитывает JSON-файл, валидирует ссылки на роли
// и атомарно обновляет lookup-map.
func (s *JSONFileStore) Reload() error {
data, err := os.ReadFile(s.path)
if err != nil {
return fmt.Errorf("невозможно прочитать %q: %w", s.path, err)
}
var cfg UsersConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("невозможно распарсить %q: %w", s.path, err)
}
resolved := make(map[string]ResolvedUser, len(cfg.Users))
for apiKey, u := range cfg.Users {
role, ok := cfg.Roles[u.RoleKey]
if !ok {
return fmt.Errorf("пользователь %q ссылается на несуществующую роль %q", u.Name, u.RoleKey)
}
if !u.Enabled {
continue
}
resolved[apiKey] = ResolvedUser{
APIKey: apiKey,
Name: u.Name,
Role: role,
RoleKey: u.RoleKey,
}
}
s.mu.Lock()
s.users = resolved
s.mu.Unlock()
s.logger.Info("Конфигурация пользователей загружена",
"path", s.path,
"active_users", len(resolved),
"total_users", len(cfg.Users),
)
return nil
}
// LookupByKey находит активного пользователя по API-ключу
func (s *JSONFileStore) LookupByKey(apiKey string) (ResolvedUser, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[apiKey]
return u, ok
}

View File

@@ -0,0 +1,52 @@
package auth
import (
"net/http"
"strings"
)
// URLKeyMiddleware извлекает API-ключ из URL-пути вида /auth/<key>/...
// и преобразует его в стандартный заголовок Authorization: Bearer <key>.
//
// Необходим для клиентов (Codex), которые не поддерживают
// передачу API-ключа через HTTP-заголовки.
//
// Пример преобразования:
//
// /auth/fedya-key-1234/v1/chat/completions
// → путь: /v1/chat/completions
// → заголовок: Authorization: Bearer fedya-key-1234
func URLKeyMiddleware(next http.Handler) http.Handler {
const prefix = "/auth/"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, prefix) {
next.ServeHTTP(w, r)
return
}
rest := r.URL.Path[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash <= 0 {
next.ServeHTTP(w, r)
return
}
key := rest[:slash]
newPath := rest[slash:]
r2 := r.Clone(r.Context())
r2.URL.Path = newPath
if r2.URL.RawPath != "" {
r2.URL.RawPath = newPath
}
// Заголовок устанавливается только если клиент не передал его явно.
if r2.Header.Get("Authorization") == "" {
r2.Header = r2.Header.Clone()
r2.Header.Set("Authorization", "Bearer "+key)
}
next.ServeHTTP(w, r2)
})
}

View File

@@ -11,7 +11,8 @@ import (
"github.com/joho/godotenv"
)
// LoadConfig загружает конфиг из .env (если есть) и окружения.
// LoadConfig загружает конфигурацию из файла .env (при наличии)
// и переменных окружения. Переменные окружения имеют приоритет.
func LoadConfig(logger *slog.Logger) (*Config, error) {
_ = godotenv.Load()
@@ -19,6 +20,10 @@ func LoadConfig(logger *slog.Logger) (*Config, error) {
cfg.ListenAddr = GetEnvAs("LISTEN_ADDR", ":8080", ParseString)
cfg.BackendURL = GetEnvAs("OLLAMA_BACKEND", "http://localhost:11434", ParseString)
cfg.MaxParallel = GetEnvAs("MAX_PARALLEL", 2, ParseInt)
cfg.MaxQueueSize = GetEnvAs("MAX_QUEUE_SIZE", 100, ParseInt)
cfg.UsersConfigPath = GetEnvAs("USERS_CONFIG_PATH", "users.json", ParseString)
cfg.AdminAPIKey = GetEnvAs("ADMIN_API_KEY", "", ParseString)
cfg.LoggingConfig.Instance = logger
cfg.LoggingConfig.Level = GetEnvAs("LOG_LEVEL", "info", ParseString)
@@ -30,7 +35,8 @@ func LoadConfig(logger *slog.Logger) (*Config, error) {
return cfg, nil
}
// Print выводит конфигурацию в виде таблички "KEY - VALUE".
// Print выводит текущую конфигурацию в виде таблицы.
// Используется для отладки при LOG_SHOW_DUMP=true.
func (c *Config) Print() {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "Loaded configuration:")
@@ -46,11 +52,9 @@ func (c *Config) Print() {
for i := 0; i < v.NumField(); i++ {
fieldName := t.Field(i).Name
fieldValue := v.Field(i).Interface()
if d, ok := fieldValue.(time.Duration); ok {
fieldValue = d.String()
}
fmt.Fprintf(w, "%s:\t%v\n", fieldName, fieldValue)
}
fmt.Fprintln(w, "----\t-----")

View File

@@ -10,7 +10,9 @@ import (
"time"
)
// Универсальная функция получения значения из окружения с парсером.
// GetEnvAs загружает переменную окружения key, парсит её функцией parse
// и возвращает результат. При отсутствии переменной или ошибке парсинга
// возвращает defaultVal.
func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T {
raw, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(raw) == "" {
@@ -18,23 +20,20 @@ func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T
}
v, err := parse(raw)
if err != nil {
// одна строка лога достаточно — не дублируем
slog.Warn(fmt.Sprintf("cannot parse env %q, using default", key),
slog.Warn(fmt.Sprintf("Некорректное значение переменной %q, используется значение по умолчанию", key),
"key", key, "raw", raw, "default", defaultVal, "error", err)
return defaultVal
}
return v
}
/* ====== БАЗОВЫЕ АДАПТЕРЫ ====== */
// --- Парсеры для базовых типов ---
func ParseString(s string) (string, error) { // просто возвращаем trimmed string
return strings.TrimSpace(s), nil
}
func ParseBool(s string) (bool, error) {
return strconv.ParseBool(strings.TrimSpace(s))
}
func ParseString(s string) (string, error) { return strings.TrimSpace(s), nil }
func ParseBool(s string) (bool, error) { return strconv.ParseBool(strings.TrimSpace(s)) }
func ParseFloat64(s string) (float64, error) { return strconv.ParseFloat(strings.TrimSpace(s), 64) }
func ParseURL(s string) (*url.URL, error) { return url.Parse(strings.TrimSpace(s)) }
func ParseTimeRFC3339(s string) (time.Time, error) { return time.Parse(time.RFC3339, strings.TrimSpace(s)) }
func ParseInt(s string) (int, error) {
i64, err := strconv.ParseInt(strings.TrimSpace(s), 10, 0)
@@ -50,26 +49,14 @@ func ParseUint(s string) (uint, error) {
return uint(u64), err
}
func ParseFloat64(s string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(s), 64)
}
// ParseDuration парсит строку длительности ("150ms", "2s", "1m", "24h").
func ParseDuration(s string) (time.Duration, error) {
// поддерживает "150ms", "2s", "1m", "24h"
return time.ParseDuration(strings.TrimSpace(s))
}
func ParseTimeRFC3339(s string) (time.Time, error) {
return time.Parse(time.RFC3339, strings.TrimSpace(s))
}
// --- Парсеры для списков ---
func ParseURL(s string) (*url.URL, error) {
return url.Parse(strings.TrimSpace(s))
}
/* ====== СПИСКИ (CSV/SEPARATOR) ====== */
// Универсальный адаптер для списков с произвольным парсером элемента.
// MakeListParser создаёт парсер для строки-списка с заданным разделителем.
func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(string) ([]T, error) {
return func(s string) ([]T, error) {
s = strings.TrimSpace(s)
@@ -82,7 +69,7 @@ func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(
for _, p := range parts {
v, err := itemParser(p)
if err != nil {
return nil, fmt.Errorf("cannot parse list item %q: %w", p, err)
return nil, fmt.Errorf("ошибка парсинга элемента %q: %w", p, err)
}
out = append(out, v)
}
@@ -90,7 +77,8 @@ func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(
}
}
// Частые случаи:
var ParseCSVStrings = MakeListParser[string](",", ParseString)
var ParseCSVInts = MakeListParser[int](",", ParseInt)
var ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64)
var (
ParseCSVStrings = MakeListParser[string](",", ParseString)
ParseCSVInts = MakeListParser[int](",", ParseInt)
ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64)
)

View File

@@ -2,15 +2,22 @@ package config
import "log/slog"
// LoggingConfig содержит настройки логирования.
type LoggingConfig struct {
Instance *slog.Logger
Level string
ShowCfgDump bool
}
// Config — корневая структура конфигурации приложения.
// Все поля загружаются из переменных окружения (или .env файла).
type Config struct {
ListenAddr string // env: LISTEN_ADDR, по умолчанию ":8080"
BackendURL string // env: OLLAMA_BACKEND, по умолчанию "http://localhost:11434"
ListenAddr string // LISTEN_ADDR — адрес и порт прокси (":8080")
BackendURL string // OLLAMA_BACKEND — URL бэкенда Ollama ("http://localhost:11434")
MaxParallel int // MAX_PARALLEL — количество параллельных слотов (= OLLAMA_NUM_PARALLEL)
MaxQueueSize int // MAX_QUEUE_SIZE — максимальный размер очереди ожидания
UsersConfigPath string // USERS_CONFIG_PATH — путь к файлу конфигурации пользователей
AdminAPIKey string // ADMIN_API_KEY — ключ доступа к admin API
LoggingConfig
}

View File

@@ -1,3 +1,5 @@
// Пакет logging предоставляет фабрику для создания структурированного логгера
// на основе log/slog.
package logging
import (
@@ -6,11 +8,16 @@ import (
"strings"
)
// New создаёт slog.Logger с текстовым форматом и заданным уровнем.
func New(level string) *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: ParseLevel(level)}))
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: ParseLevel(level),
}))
}
// ParseLevel преобразует строковое название уровня в slog.Level.
// Поддерживаемые значения: "debug", "info", "warn"/"warning", "error".
// При невалидном значении возвращает slog.LevelInfo.
func ParseLevel(lvl string) slog.Level {
switch strings.ToLower(lvl) {
case "debug":

View File

@@ -0,0 +1,53 @@
// Пакет queue реализует приоритетную очередь запросов
// с контролем количества параллельных слотов.
package queue
import (
"context"
"time"
)
// QueueItem — элемент приоритетной очереди.
type QueueItem struct {
Priority int // числовой приоритет (больше = выше)
EnqueuedAt time.Time // время постановки в очередь (для FIFO при равном приоритете)
Ready chan struct{} // канал сигнала о получении слота
Ctx context.Context // контекст для отмены ожидания
index int // текущая позиция в heap, управляется container/heap
}
// PriorityQueue реализует интерфейс heap.Interface.
// Элементы извлекаются в порядке убывания приоритета;
// при равном приоритете — в порядке постановки (FIFO).
type PriorityQueue []*QueueItem
func (pq PriorityQueue) Len() int { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool {
if pq[i].Priority != pq[j].Priority {
return pq[i].Priority > pq[j].Priority
}
return pq[i].EnqueuedAt.Before(pq[j].EnqueuedAt)
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index = i
pq[j].index = j
}
func (pq *PriorityQueue) Push(x any) {
item := x.(*QueueItem)
item.index = len(*pq)
*pq = append(*pq, item)
}
func (pq *PriorityQueue) Pop() any {
old := *pq
n := len(old)
item := old[n-1]
old[n-1] = nil // предотвращение утечки памяти
item.index = -1
*pq = old[:n-1]
return item
}

View File

@@ -0,0 +1,66 @@
package queue
import (
"encoding/json"
"log/slog"
"net/http"
)
// PriorityFunc определяет приоритет запроса для постановки в очередь.
// Вызывается middleware перед Acquire. Возвращает целое число:
// чем больше значение — тем выше приоритет.
type PriorityFunc func(r *http.Request) int
// Middleware ограничивает количество параллельных запросов к бэкенду.
// Запросы, не получившие слот, ожидают в приоритетной очереди.
// priorityFn определяет приоритет каждого запроса; при nil приоритет = 1.
func Middleware(logger *slog.Logger, scheduler *Scheduler, priorityFn PriorityFunc, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
priority := 1
if priorityFn != nil {
priority = priorityFn(r)
}
waited, err := scheduler.Acquire(r.Context(), priority)
if err != nil {
handleAcquireError(logger, w, r, err, scheduler)
return
}
if waited {
inFlight, queued := scheduler.Stats()
logger.Info("запрос вышел из очереди",
"method", r.Method, "path", r.URL.Path,
"priority", priority, "in_flight", inFlight, "queued", queued,
)
}
defer scheduler.Release()
next.ServeHTTP(w, r)
})
}
// handleAcquireError формирует HTTP-ответ при ошибке получения слота.
func handleAcquireError(logger *slog.Logger, w http.ResponseWriter, r *http.Request, err error, scheduler *Scheduler) {
w.Header().Set("Content-Type", "application/json")
switch err {
case ErrQueueFull:
inFlight, queued := scheduler.Stats()
logger.Warn("очередь переполнена",
"method", r.Method, "path", r.URL.Path,
"in_flight", inFlight, "queued", queued,
)
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"error": "server overloaded, queue full",
})
case ErrRequestCancelled:
logger.Debug("клиент отключился в очереди",
"method", r.Method, "path", r.URL.Path,
)
w.WriteHeader(http.StatusGatewayTimeout)
json.NewEncoder(w).Encode(map[string]string{
"error": "request cancelled while queued",
})
}
}

View File

@@ -0,0 +1,110 @@
package queue
import (
"container/heap"
"context"
"errors"
"sync"
"time"
)
var (
ErrQueueFull = errors.New("queue is full")
ErrRequestCancelled = errors.New("request cancelled while queued")
)
// Scheduler управляет слотами параллельных запросов и очередью ожидания.
// Количество слотов должно соответствовать OLLAMA_NUM_PARALLEL на сервере Ollama.
type Scheduler struct {
mu sync.Mutex
maxSlots int
inFlight int
waiting PriorityQueue
maxQueue int
}
// NewScheduler создаёт планировщик с заданным количеством слотов и размером очереди.
func NewScheduler(maxSlots, maxQueue int) *Scheduler {
s := &Scheduler{
maxSlots: maxSlots,
maxQueue: maxQueue,
}
heap.Init(&s.waiting)
return s
}
// Acquire запрашивает слот для выполнения запроса.
//
// Быстрый путь: если есть свободный слот — возвращает (false, nil) немедленно.
// Медленный путь: запрос ставится в приоритетную очередь и блокируется
// до получения слота или отмены контекста.
//
// Возвращаемое значение waited=true означает, что запрос ожидал в очереди.
func (s *Scheduler) Acquire(ctx context.Context, priority int) (waited bool, err error) {
s.mu.Lock()
if s.inFlight < s.maxSlots {
s.inFlight++
s.mu.Unlock()
return false, nil
}
if s.waiting.Len() >= s.maxQueue {
s.mu.Unlock()
return false, ErrQueueFull
}
item := &QueueItem{
Priority: priority,
EnqueuedAt: time.Now(),
Ready: make(chan struct{}, 1),
Ctx: ctx,
}
heap.Push(&s.waiting, item)
s.mu.Unlock()
select {
case <-item.Ready:
return true, nil
case <-ctx.Done():
s.mu.Lock()
if item.index >= 0 {
heap.Remove(&s.waiting, item.index)
s.mu.Unlock()
return true, ErrRequestCancelled
}
// Элемент уже извлечён из очереди — слот передан этому запросу.
s.mu.Unlock()
// Канал Ready читается для предотвращения утечки горутины.
select {
case <-item.Ready:
default:
}
return true, ErrRequestCancelled
}
}
// Release освобождает слот и передаёт его следующему ожидающему запросу.
// Отменённые запросы (контекст закрыт) пропускаются.
func (s *Scheduler) Release() {
s.mu.Lock()
defer s.mu.Unlock()
for s.waiting.Len() > 0 {
next := heap.Pop(&s.waiting).(*QueueItem)
if next.Ctx.Err() != nil {
continue
}
next.Ready <- struct{}{}
return
}
s.inFlight--
}
// Stats возвращает количество активных запросов и длину очереди.
func (s *Scheduler) Stats() (inFlight, queued int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.inFlight, s.waiting.Len()
}

View File

@@ -0,0 +1,88 @@
// Пакет ratelimit реализует ограничение частоты запросов
// по алгоритму скользящего окна (sliding window) для каждого API-ключа.
package ratelimit
import (
"sync"
"time"
)
// Limiter хранит историю запросов по ключам
// и определяет, разрешён ли очередной запрос.
type Limiter struct {
mu sync.Mutex
windows map[string][]time.Time
stopCh chan struct{}
}
// New создаёт Limiter и запускает фоновую горутину очистки устаревших записей.
// Для корректного завершения необходимо вызвать Stop().
func New() *Limiter {
l := &Limiter{
windows: make(map[string][]time.Time),
stopCh: make(chan struct{}),
}
go l.cleanup()
return l
}
// Allow проверяет, не превышен ли лимит maxReqs за период window для данного ключа.
// Если запрос разрешён — регистрирует его и возвращает true.
func (l *Limiter) Allow(key string, maxReqs int, window time.Duration) bool {
now := time.Now()
cutoff := now.Add(-window)
l.mu.Lock()
defer l.mu.Unlock()
timestamps := l.windows[key]
// Удаление записей, выпавших за пределы окна.
start := 0
for start < len(timestamps) && timestamps[start].Before(cutoff) {
start++
}
timestamps = timestamps[start:]
if len(timestamps) >= maxReqs {
l.windows[key] = timestamps
return false
}
l.windows[key] = append(timestamps, now)
return true
}
// Stop останавливает фоновую горутину очистки.
func (l *Limiter) Stop() {
close(l.stopCh)
}
// cleanup периодически удаляет устаревшие записи из всех окон.
// Интервал очистки — 5 минут, записи старше 10 минут удаляются.
func (l *Limiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute)
for key, timestamps := range l.windows {
start := 0
for start < len(timestamps) && timestamps[start].Before(cutoff) {
start++
}
if start == len(timestamps) {
delete(l.windows, key)
} else {
l.windows[key] = timestamps[start:]
}
}
l.mu.Unlock()
case <-l.stopCh:
return
}
}
}

View File

@@ -0,0 +1,48 @@
package ratelimit
import (
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"backend/src/internal/auth"
)
// Middleware ограничивает частоту запросов на основе RateLimitConfig роли пользователя.
// Для неаутентифицированных запросов и ролей без заданного лимита — пропускает.
// При превышении лимита возвращает 429 Too Many Requests с заголовком Retry-After.
func Middleware(logger *slog.Logger, limiter *Limiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
rl := user.Role.RateLimit
if rl.Requests <= 0 {
next.ServeHTTP(w, r)
return
}
window := rl.WindowDuration()
if !limiter.Allow(user.APIKey, rl.Requests, window) {
retryAfter := int(math.Ceil(window.Seconds()))
logger.Warn("Превышен rate limit",
"user", user.Name, "role", user.RoleKey,
"limit", rl.Requests, "window", window.String(),
)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]string{
"error": "rate limit exceeded",
})
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,20 +1,35 @@
// Точка входа Ollama Proxy — обратного прокси-сервера для Ollama
// с приоритетной очередью, аутентификацией, rate limiting
// и контролем моделей/контекста.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"backend/src/internal/admin"
"backend/src/internal/auth"
"backend/src/internal/config"
"backend/src/internal/logging"
"backend/src/internal/queue"
"backend/src/internal/ratelimit"
)
const (
AppName = "Ollama Proxy"
AppVersion = "1.0.0"
AppVersion = "2.0.0"
// Таймаут ожидания завершения активных запросов при остановке сервера.
shutdownTimeout = 15 * time.Second
)
func main() {
@@ -28,9 +43,10 @@ func main() {
return
}
// Пересоздание логгера с уровнем из конфигурации.
level := logging.ParseLevel(cfg.LoggingConfig.Level)
if level != slog.LevelInfo {
logger.Info("Уровень логирования из env", "level", level.String())
logger.Info("Уровень логирования из конфигурации", "level", level.String())
}
logger = logging.New(cfg.LoggingConfig.Level)
slog.SetDefault(logger)
@@ -38,29 +54,120 @@ func main() {
target, err := url.Parse(cfg.BackendURL)
if err != nil {
logger.Error("Неверный URL бэкенда", "error", err)
logger.Error("Некорректный URL бэкенда", "error", err)
return
}
// Загрузка конфигурации пользователей.
// При отсутствии файла прокси работает без аутентификации.
store, err := auth.NewJSONFileStore(cfg.UsersConfigPath, logger)
if err != nil {
logger.Warn("Конфигурация пользователей не загружена — аутентификация отключена", "error", err)
store = nil
}
scheduler := queue.NewScheduler(cfg.MaxParallel, cfg.MaxQueueSize)
limiter := ratelimit.New()
defer limiter.Stop()
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(target)
r.Out.Host = target.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
logger.Error("Ошибка прокси", "method", r.Method, "path", r.URL.Path, "error", err)
logger.Error("Ошибка проксирования", "method", r.Method, "path", r.URL.Path, "error", err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
},
}
handler := loggingMiddleware(logger, proxy)
handler := buildMiddlewareChain(proxy, store, logger, scheduler, limiter)
logger.Info("Прокси запущен", "addr", cfg.ListenAddr, "backend", cfg.BackendURL)
if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil {
mux := http.NewServeMux()
if store != nil && cfg.AdminAPIKey != "" {
adminHandler := admin.NewHandler(logger, store, cfg.AdminAPIKey)
mux.Handle("/admin/reload", adminHandler.ReloadHandler())
}
mux.Handle("/", handler)
server := &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
}
logger.Info("Прокси запущен",
"addr", cfg.ListenAddr,
"backend", cfg.BackendURL,
"max_parallel", cfg.MaxParallel,
"max_queue", cfg.MaxQueueSize,
"auth_enabled", store != nil,
)
// Запуск в отдельной горутине для поддержки graceful shutdown.
errCh := make(chan error, 1)
go func() {
errCh <- server.ListenAndServe()
}()
// Ожидание сигнала завершения (Ctrl+C, systemd stop).
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-quit:
logger.Info("Получен сигнал завершения", "signal", sig.String())
case err := <-errCh:
logger.Error("Ошибка сервера", "error", err)
return
}
// Graceful shutdown: завершение активных запросов с таймаутом.
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("Ошибка при остановке сервера", "error", err)
} else {
logger.Info("Сервер остановлен")
}
}
// buildMiddlewareChain собирает цепочку middleware.
// Порядок обработки запроса:
//
// logging → urlkey → auth → modelcheck → ratelimit → queue → proxy
func buildMiddlewareChain(
proxy http.Handler,
store *auth.JSONFileStore,
logger *slog.Logger,
scheduler *queue.Scheduler,
limiter *ratelimit.Limiter,
) http.Handler {
// Приоритет запроса определяется ролью аутентифицированного пользователя.
// Без аутентификации все запросы имеют одинаковый приоритет.
priorityFn := queue.PriorityFunc(func(r *http.Request) int {
if user, ok := auth.UserFromContext(r.Context()); ok {
return user.Role.Priority
}
return 1
})
var handler http.Handler = proxy
handler = queue.Middleware(logger, scheduler, priorityFn, handler)
if store != nil {
handler = ratelimit.Middleware(logger, limiter, handler)
handler = auth.ModelCheckMiddleware(logger, handler)
handler = auth.AuthMiddleware(logger, store, handler)
handler = auth.URLKeyMiddleware(handler)
}
handler = loggingMiddleware(logger, handler)
return handler
}
// loggingMiddleware записывает метод, путь, статус и время обработки каждого запроса.
// Ключ в URL вида /auth/<key>/... маскируется перед записью в лог.
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
@@ -68,13 +175,31 @@ func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
next.ServeHTTP(rw, r)
logger.Info("запрос",
"method", r.Method,
"path", r.URL.Path,
"path", maskAuthPath(r.URL.Path),
"status", rw.status,
"duration", time.Since(start).String(),
)
})
}
// maskAuthPath маскирует API-ключ в путях вида /auth/<key>/...
// Пример: /auth/petrov-key-5678/v1/chat → /auth/***/v1/chat
func maskAuthPath(path string) string {
const prefix = "/auth/"
if !strings.HasPrefix(path, prefix) {
return path
}
rest := path[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash <= 0 {
return prefix + "***"
}
return prefix + "***" + rest[slash:]
}
// responseWriter оборачивает http.ResponseWriter для перехвата HTTP-статуса.
// Реализует http.Flusher для поддержки streaming (SSE) и
// Unwrap() для совместимости с http.ResponseController (Go 1.20+).
type responseWriter struct {
http.ResponseWriter
status int
@@ -84,3 +209,18 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// Flush передаёт буферизированные данные клиенту.
// Необходим для корректной работы streaming-ответов от Ollama.
func (rw *responseWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap возвращает оригинальный ResponseWriter.
// Требуется для http.ResponseController (Go 1.20+),
// который использует Unwrap для доступа к нижележащим интерфейсам.
func (rw *responseWriter) Unwrap() http.ResponseWriter {
return rw.ResponseWriter
}

20
users.example.json Normal file
View File

@@ -0,0 +1,20 @@
{
"roles": {
"vip": {
"priority": 100,
"allowed_models": ["qwen3:32b", "qwen3:0.6b"],
"max_context_length": 32768,
"rate_limit": { "requests": 60, "window": "1m" }
},
"regular": {
"priority": 10,
"allowed_models": ["qwen3:0.6b"],
"max_context_length": 8192,
"rate_limit": { "requests": 20, "window": "1m" }
}
},
"users": {
"key-abc123": { "name": "Иванов", "role": "vip", "enabled": true },
"key-xyz789": { "name": "Петров", "role": "regular", "enabled": true }
}
}