diff --git a/README.md b/README.md index 227c60f..97e9cf2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,366 @@ -# Прокси для расписания в Modeus +# SfeduSchedule -## TODO +`SfeduSchedule` это ASP.NET Core-приложение, которое работает как прокси и вспомогательный API поверх `Modeus`. Проект умеет: -- [x] Добавить RateLimiter -- [x] Добавить обработку ошибок при запросах к modeus -- [ ] Добавить кэширование расписания -- [ ] Сделать передачу ошибок выше по цепочке \ No newline at end of file +- проксировать запросы к расписанию и поиску аудиторий в `Modeus`; +- отдавать расписание в `ICS`; +- искать GUID пользователя по ФИО; +- поддерживать веб-вход через Microsoft для получения GUID текущего пользователя; +- периодически обновлять JWT и локальный индекс сотрудников; +- публиковать метрики Prometheus; +- загружать внешние плагины из `data/Plugins`. + +Основной проект находится в [SfeduSchedule/SfeduSchedule.csproj](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/SfeduSchedule.csproj), общие DTO и контракт плагинов в [ModeusSchedule.Abstractions](/home/serega404/Рабочий стол/SfeduSchedule/ModeusSchedule.Abstractions), пример плагина в [SfeduSchedule.Plugin.Sample](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule.Plugin.Sample). + +## Как это работает + +На старте приложение: + +1. Загружает конфигурацию из `appsettings`, переменных окружения и других стандартных источников .NET. +2. Проставляет значения по умолчанию для части переменных, если они не заданы. +3. Создаёт рабочую директорию `data/`, а внутри неё: + - `jwt.txt` для сохранённого JWT; + - `employees.json` для локального индекса сотрудников; + - `keys/` для ключей Data Protection; + - `Plugins/` для загружаемых плагинов. +4. Настраивает логирование в консоль, а при наличии `TG_CHAT_ID` и `TG_TOKEN` ещё и отправку ошибок в Telegram. +5. Подключает Sentry, Swagger, Prometheus, rate limiter, API key auth и OpenID Connect через `Microsoft.Identity.Web`. +6. Загружает все плагины `*.plugin.dll` из `data/Plugins` в отдельном `AssemblyLoadContext`. +7. Инициализирует Quartz-задачи: + - обновление JWT; + - обновление локального списка сотрудников; + - цепочку, при которой после успешного обновления JWT сразу запускается обновление сотрудников. + +После запуска приложение выбирает источник JWT в таком порядке: + +1. Если задан `TOKEN`, используется он, а фоновое обновление JWT отключается. +2. Иначе, если существует `data/jwt.txt` и токен считается ещё пригодным, используется он. +3. Иначе приложение триггерит `UpdateJwtJob`, который запрашивает новый JWT у внешнего auth-сервиса. + +Когда JWT уже есть, приложение использует его для запросов к `Modeus`. + +## Основные возможности + +### 1. Проксирование Modeus API + +Контроллер [ProxyController](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Controllers/ProxyController.cs) пробрасывает запросы в `Modeus`: + +- `POST /api/proxy/events/search` — получить расписание; +- `POST /api/proxy/rooms/search` — искать аудитории. + +Если `Modeus` отвечает ошибкой, сервис возвращает соответствующий HTTP-статус и текст с префиксом `Proxied Modeus:`. + +### 2. Работа с расписанием + +Контроллер [ScheduleController](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Controllers/ScheduleController.cs) предоставляет: + +- `GET /api/schedule/getguid?fullname=...` — получить GUID по полному имени, защищено `X-Api-Key`; +- `GET /api/schedule/searchemployee?fullname=...` — искать сотрудников по локальному индексу; +- `GET /api/schedule/ics?attendeePersonId=...` — получить `ICS` для одного пользователя; +- `POST /api/schedule/ics` — получить `ICS` по кастомному `ModeusScheduleRequest`. + +Генерация `ICS` происходит в [ModeusService](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Services/ModeusService.cs): сервис получает JSON расписания из `Modeus`, вытаскивает аудитории, преподавателей, краткие названия дисциплин и формирует календарь через `Ical.Net`. + +Диапазон у `GET /api/schedule/ics` зашит в коде: от текущего момента минус 7 дней до плюс 1 месяца. + +### 3. GUID через Microsoft-вход + +Контроллер [SfeduController](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Controllers/SfeduController.cs) отдаёт: + +- `GET /api/sfedu/guid` + +Маршрут защищён схемой `OpenIdConnect`. После логина приложение берёт claim `name`, ищет GUID через `Modeus` и: + +- либо возвращает GUID текстом; +- либо делает redirect на `redirectUri?guid=...`, если передан параметр `redirectUri`. + +Для этого сценария должны быть корректно настроены переменные `AzureAd:*`. + +### 4. Локальный индекс сотрудников + +[ModeusEmployeeService](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Services/ModeusEmployeeService.cs) хранит в памяти словарь сотрудников и их должностей, а также сохраняет его в `data/employees.json`. + +Обновление выполняет Quartz job [UpdateEmployeesJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateEmployeesJob.cs): + +- ходит в `Modeus`; +- загружает большой список сотрудников; +- группирует записи по ФИО; +- сохраняет результат на диск; +- повторяет попытки при временных сбоях. + +Если сервис ещё не инициализирован, `GET /api/schedule/searchemployee` возвращает `503`. + +### 5. JWT lifecycle + +Quartz job [UpdateJwtJob](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Jobs/UpdateJWTJob.cs): + +- идёт во внешний auth-сервис по `AUTH_URL`; +- опционально передаёт `X-API-Key`, если задан `AUTH_API_KEY`; +- ожидает JSON вида `{ "jwt": "..." }`; +- записывает JWT в runtime-конфигурацию, в `ModeusHttpClient` и в `data/jwt.txt`. + +Если `TOKEN` задан вручную, job не получает триггер по cron и приложение использует этот токен как основной. + +### 6. Метрики и ограничения доступа + +Приложение публикует метрики `prometheus-net` на `/metrics`. + +Для `/metrics` добавлен middleware [LocalNetworksOnlyMiddleware](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs): + +- разрешён только `GET`; +- доступ открыт только из loopback, RFC1918 private networks, link-local и local IPv6 диапазонов; +- для остальных адресов возвращается `403`. + +### 7. Ограничение частоты запросов + +Для части API включён rate limit с фиксированным окном: + +- по умолчанию `40` запросов; +- за окно `10` секунд; +- ключом клиента считается первый IP из `X-Forwarded-For`, либо `RemoteIpAddress`. + +При превышении лимита приложение отвечает `429` и добавляет заголовок `Retry-After`. + +`GET /api/schedule/getguid` специально исключён из rate limiting. + +### 8. Плагины + +На старте хост ищет все `*.plugin.dll` внутри `data/Plugins` и загружает найденные сборки как плагины. Плагин должен реализовывать интерфейс [IPlugin](/home/serega404/Рабочий стол/SfeduSchedule/ModeusSchedule.Abstractions/IPlugin.cs), чтобы: + +- зарегистрировать свои сервисы в DI через `ConfigureServices`; +- добавить minimal API маршруты через `MapEndpoints`. + +MVC-контроллеры из плагинов тоже подключаются автоматически через `ApplicationPart`. + +В репозитории есть пример в [SfeduSchedule.Plugin.Sample/Plugin.cs](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule.Plugin.Sample/Plugin.cs). + +## API endpoints + +### Публичные и служебные + +- `GET /` — статическая стартовая страница из `wwwroot/index.html` +- `GET /api/docs` — Swagger UI +- `GET /api/docs/{documentName}/swagger.json` — OpenAPI JSON +- `GET /metrics` — Prometheus metrics, только из локальных/приватных сетей + +### Schedule API + +- `GET /api/schedule/getguid?fullname=...` + Требует `X-Api-Key` +- `GET /api/schedule/searchemployee?fullname=...` +- `GET /api/schedule/ics?attendeePersonId=...` +- `POST /api/schedule/ics` + +### Proxy API + +- `POST /api/proxy/events/search` +- `POST /api/proxy/rooms/search` + +### Sfedu API + +- `GET /api/sfedu/guid` + Требует вход через Microsoft / OpenID Connect + +## Переменные окружения + +Ниже перечислены переменные, которые реально используются кодом или docker-compose файлами проекта. + +### Ключевые переменные приложения + + +| Переменная | Обязательность | Значение по умолчанию | Где используется | Назначение | +| ------------------------- | --------------------------------------- | ------------------------------------------ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MODEUS_URL` | Нет | `https://sfedu.modeus.org/` | код | Базовый URL`Modeus` | +| `TOKEN` | Условно обязательна | нет | код | Готовый JWT для`Modeus`; если не задан, приложение попытается получить токен через `AUTH_URL` | +| `AUTH_URL` | Условно обязательна | `http://msauth:8080/auth/ms` | код | URL сервиса, который возвращает новый JWT | +| `AUTH_API_KEY` | Нет | пусто | код | API key для вызова`AUTH_URL`, передаётся как `X-API-Key` | +| `API_KEY` | Условно обязательна | нет | код | Ключ для маршрутов, защищённых схемой`ApiKey` | +| `UPDATE_JWT_CRON` | Нет | `0 0 4 ? * *` | код | Cron для фонового обновления JWT | +| `UPDATE_EMPLOYEES_CRON` | Нет | `0 0 6 ? * *` | код | Cron для фонового обновления индекса сотрудников | +| `PERMIT_LIMIT` | Нет | `40` | код | Лимит запросов в окне rate limiter | +| `TIME_LIMIT` | Нет | `10` | код | Длина окна rate limiter в секундах | +| `TZ` | Нет | `Europe/Moscow` | код | Таймзона для запросов к`Modeus` и генерации `ICS` | +| `TG_CHAT_ID` | Нет | нет | код | Chat ID для Telegram-логирования | +| `TG_TOKEN` | Нет | нет | код | Bot token для Telegram-логирования | +| `Sentry:Dsn` | Нет | пустая строка | код | DSN для Sentry | + +### Переменные для Microsoft / Azure AD + +Эти значения напрямую читает `Microsoft.Identity.Web`. В compose-файлах они передаются в контейнер, а в коде используются для `OpenIdConnect`-схемы. + + +| Переменная | Обязательность | Значение по умолчанию | Назначение | +| ------------------------ | --------------------------------------- | ------------------------------------------ | -------------------------------------- | +| `AzureAd:Instance` | Условно обязательна | нет | Базовый URL identity provider | +| `AzureAd:TenantId` | Условно обязательна | нет | Tenant ID или tenant domain | +| `AzureAd:ClientId` | Условно обязательна | нет | Client ID приложения | +| `AzureAd:ClientSecret` | Условно обязательна | нет | Client secret приложения | +| `AzureAd:Domain` | Условно обязательна | нет | Azure AD domain | +| `AzureAd:CallbackPath` | Условно обязательна | нет | Callback path для OIDC | + +Обязательность у `AzureAd:*` условная: без них приложение может быть полезно как API-прокси, но маршрут `GET /api/sfedu/guid` и связанный login-flow работать корректно не будут. + +### Переменные из docker-compose + + +| Переменная | Обязательность | Значение по умолчанию | Назначение | +| --------------------------------------- | ------------------------------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `ASPNETCORE_FORWARDEDHEADERS_ENABLED` | Нет | нет | В compose выставляется в`true`, чтобы приложение корректнее работало за reverse proxy | + +### Что значит "условно обязательна" + +`Условно обязательна` означает, что переменная нужна не всегда, а только для конкретного сценария: + +- нужен `TOKEN` или рабочий `AUTH_URL`, чтобы ходить в `Modeus`; +- нужен `API_KEY`, если вы хотите использовать `GET /api/schedule/getguid`; +- нужны `AzureAd:*`, если используется `GET /api/sfedu/guid`; +- нужны `TG_CHAT_ID` и `TG_TOKEN`, если хотите логирование в Telegram; +- нужен `Sentry:Dsn`, если хотите отправлять ошибки и трейсы в Sentry. + +## Пример `.env` + +Для запуска через `docker compose` можно использовать примерно такой `.env`: + +```env +API_KEY=change-me + +AUTH_URL=http://msauth:8080/auth/ms +AUTH_API_KEY= + +MODEUS_URL=https://sfedu.modeus.org/ +TZ=Europe/Moscow + +UPDATE_JWT_CRON=0 0 4 ? * * +UPDATE_EMPLOYEES_CRON=0 0 6 ? * * + +PERMIT_LIMIT=40 +TIME_LIMIT=10 + +TG_CHAT_ID= +TG_TOKEN= + +SENTRY_DSN= + +AzureAd:Instance=https://login.microsoftonline.com/ +AzureAd:TenantId=sfedu.ru +AzureAd:ClientId= +AzureAd:ClientSecret= +AzureAd:Domain=sfedu.onmicrosoft.com +AzureAd:CallbackPath=/signin-oidc +``` + +Если вы передаёте переменные именно как environment variables вне `docker compose`, для иерархических .NET-ключей чаще используют форму с двойным подчёркиванием: + +```env +AzureAd__Instance=https://login.microsoftonline.com/ +Sentry__Dsn= +``` + +## Локальный запуск + +### Требования + +- `.NET SDK 10` +- доступ к `Modeus` +- либо рабочий `TOKEN`, либо доступный `AUTH_URL` + +### Запуск из исходников + +```bash +dotnet restore SfeduSchedule.sln +dotnet run --project SfeduSchedule/SfeduSchedule.csproj +``` + +Профили локального запуска прописаны в [launchSettings.json](/home/serega404/Рабочий стол/SfeduSchedule/SfeduSchedule/Properties/launchSettings.json): + +- `http://localhost:5087` +- `https://localhost:7146` + +## Запуск в Docker + +### Production compose + +Файл [docker-compose-prod.yml](/home/serega404/Рабочий стол/SfeduSchedule/docker-compose-prod.yml): + +- поднимает контейнер `SfeduSchedule`; +- публикует порт `8088 -> 8080`; +- использует именованный volume `data:/app/data`; +- ожидает готовый образ `git.zetcraft.ru/serega404/sfeduschedule:main`. + +Пример запуска: + +```bash +docker compose -f docker-compose-prod.yml up -d +``` + +### Test compose + +Файл [docker-compose-test.yml](/home/serega404/Рабочий стол/SfeduSchedule/docker-compose-test.yml) предназначен для локальной сборки и монтирует `./data:/app/data`. + +Пример запуска: + +```bash +docker compose -f docker-compose-test.yml up --build +``` + +Важно: в текущем виде `docker-compose-test.yml` ссылается на `dockerfile: Dockerfile` при `context: ./SfeduSchedule`, тогда как `Dockerfile` лежит в корне репозитория. Перед использованием этот compose-файл, вероятно, нужно скорректировать под фактическую структуру проекта. + +## Структура данных в `data/` + +Во время работы приложение заполняет: + +- `data/jwt.txt` — первая строка JWT, вторая строка timestamp его сохранения; +- `data/employees.json` — локальный индекс сотрудников; +- `data/keys/` — ключи Data Protection; +- `data/Plugins/` — каталог для плагинов. + +Эту директорию стоит сохранять между рестартами контейнера. + +## Безопасность и эксплуатационные детали + +- `API_KEY` можно передавать в заголовке `X-Api-Key`, а также в query string как `api_key`. +- Для `X-Api-Key` используется сравнение в постоянное время. +- Приложение обрабатывает `X-Forwarded-For`, `X-Forwarded-Proto` и `X-Forwarded-Host`. +- В ответ на каждый HTTP-запрос добавляется `X-Correlation-ID`. +- Swagger опубликован по адресу `/api/docs`, а не по стандартному `/swagger`. +- Ключи Data Protection сохраняются на диск, поэтому cookie и служебные ключи не меняются на каждый рестарт. + +## Плагины + +Чтобы подключить плагин: + +1. Соберите библиотеку с именем вида `*.plugin.dll`. +2. Убедитесь, что она реализует `ModeusSchedule.Abstractions.IPlugin`. +3. Положите сборку и её зависимости в `data/Plugins/<имя-плагина>/`. +4. Перезапустите приложение. + +Хост: + +- создаст для плагина отдельный `AssemblyLoadContext`; +- вызовет `ConfigureServices`; +- подключит MVC-контроллеры из сборки; +- вызовет `MapEndpoints` после сборки приложения. + +## Что стоит учитывать + +- `GET /api/schedule/searchemployee` зависит от успешной инициализации локального индекса сотрудников. +- Если не настроен `AUTH_URL` и не задан `TOKEN`, приложение не сможет полноценно работать с `Modeus`. +- Проверка срока жизни JWT в `jwt.txt` основана на времени сохранения файла, а не на `exp` claim токена. +- В коде включены `Sentry` tracing и profiling с sample rate `1.0`, это может быть важно для нагрузки и объёма телеметрии. + +## Проверка после запуска + +Минимальный smoke check: + +```bash +curl http://localhost:5087/ +curl http://localhost:5087/api/docs +curl http://localhost:5087/metrics +``` + +Для защищённого эндпоинта: + +```bash +curl -H "X-Api-Key: $API_KEY" \ + "http://localhost:5087/api/schedule/getguid?fullname=Иванов%20Иван%20Иванович" +```