diff --git a/.gitignore b/.gitignore index 639dd42..17cb10e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .env .vscode/ .DS_Store +.claude/ +# конфиг пользователей (содержит API-ключи) +users.json # собранные бинарники *.exe diff --git a/SETUP_WIN_SERVER.md b/SETUP_WIN_SERVER.md new file mode 100644 index 0000000..6665a30 --- /dev/null +++ b/SETUP_WIN_SERVER.md @@ -0,0 +1,482 @@ +# Настройка Ollama Proxy +--- + +## Архитектура + +``` +Codex (клиентский ноутбук) + ↓ HTTP-запрос с API-ключом в URL +http://: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 +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://:8080/auth/<ключ>/v1" +wire_api = "responses" +``` + +**Что нужно заменить:** + +**``** — 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 не получит преимущества. diff --git a/src/internal/admin/handler.go b/src/internal/admin/handler.go new file mode 100644 index 0000000..4357151 --- /dev/null +++ b/src/internal/admin/handler.go @@ -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) +} diff --git a/src/internal/auth/middleware.go b/src/internal/auth/middleware.go new file mode 100644 index 0000000..3520eae --- /dev/null +++ b/src/internal/auth/middleware.go @@ -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 , +// находит пользователя в 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 ") + 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] + "***" +} diff --git a/src/internal/auth/models.go b/src/internal/auth/models.go new file mode 100644 index 0000000..a60860e --- /dev/null +++ b/src/internal/auth/models.go @@ -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 +} diff --git a/src/internal/auth/store.go b/src/internal/auth/store.go new file mode 100644 index 0000000..6c4481a --- /dev/null +++ b/src/internal/auth/store.go @@ -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 +} diff --git a/src/internal/auth/urlkey.go b/src/internal/auth/urlkey.go new file mode 100644 index 0000000..bed0398 --- /dev/null +++ b/src/internal/auth/urlkey.go @@ -0,0 +1,52 @@ +package auth + +import ( + "net/http" + "strings" +) + +// URLKeyMiddleware извлекает API-ключ из URL-пути вида /auth//... +// и преобразует его в стандартный заголовок Authorization: Bearer . +// +// Необходим для клиентов (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) + }) +} diff --git a/src/internal/config/base.go b/src/internal/config/base.go index 199e620..c2d125f 100644 --- a/src/internal/config/base.go +++ b/src/internal/config/base.go @@ -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-----") diff --git a/src/internal/config/environment.go b/src/internal/config/environment.go index 6e6a215..0c9ccdd 100644 --- a/src/internal/config/environment.go +++ b/src/internal/config/environment.go @@ -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) +) diff --git a/src/internal/config/interface.go b/src/internal/config/interface.go index c186885..16174b2 100644 --- a/src/internal/config/interface.go +++ b/src/internal/config/interface.go @@ -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 } diff --git a/src/internal/logging/logging.go b/src/internal/logging/logging.go index a531cc9..7c4ba3b 100644 --- a/src/internal/logging/logging.go +++ b/src/internal/logging/logging.go @@ -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": diff --git a/src/internal/queue/heap.go b/src/internal/queue/heap.go new file mode 100644 index 0000000..dcb0ed2 --- /dev/null +++ b/src/internal/queue/heap.go @@ -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 +} diff --git a/src/internal/queue/middleware.go b/src/internal/queue/middleware.go new file mode 100644 index 0000000..bd1bbe8 --- /dev/null +++ b/src/internal/queue/middleware.go @@ -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", + }) + } +} diff --git a/src/internal/queue/scheduler.go b/src/internal/queue/scheduler.go new file mode 100644 index 0000000..77e4f87 --- /dev/null +++ b/src/internal/queue/scheduler.go @@ -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() +} diff --git a/src/internal/ratelimit/limiter.go b/src/internal/ratelimit/limiter.go new file mode 100644 index 0000000..f2950e9 --- /dev/null +++ b/src/internal/ratelimit/limiter.go @@ -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 + } + } +} diff --git a/src/internal/ratelimit/middleware.go b/src/internal/ratelimit/middleware.go new file mode 100644 index 0000000..a484e17 --- /dev/null +++ b/src/internal/ratelimit/middleware.go @@ -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) + }) +} diff --git a/src/main.go b/src/main.go index c3e0040..bb7a124 100644 --- a/src/main.go +++ b/src/main.go @@ -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//... маскируется перед записью в лог. 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//... +// Пример: /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 +} diff --git a/users.example.json b/users.example.json new file mode 100644 index 0000000..f8ddbb0 --- /dev/null +++ b/users.example.json @@ -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 } + } +}