From b52080e026cab1552dd241ff1cd9af90b0cff02e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 21:41:33 +0300 Subject: [PATCH] Refactor Dockerfile and documentation for non-root user setup. Update CLAUDE.md and README.md to clarify media storage permissions and user requirements. Modify appsettings for local development database configuration. Enhance MediaEndpoints by removing unused regex and improving file resolution logic. Update BumperPreview to use SHA-256 for asset ID generation, ensuring better security practices. --- CLAUDE.md | 9 ++- Dockerfile | 9 ++- README.md | 17 ++++++ .../TeleWave.Api/Endpoints/MediaEndpoints.cs | 27 +-------- .../TeleWave.Api/appsettings.Development.json | 6 ++ backend/src/TeleWave.Api/appsettings.json | 3 - .../Broadcast/Bumpers/BumperPreview.cs | 10 +++- .../Library/EpisodeName.cs | 14 ++++- docs/server-storage-setup.md | 59 ++++++++++++++----- 9 files changed, 103 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99ab2a4..406d96e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,9 +84,12 @@ - Один образ: Api раздаёт REST (`/api`) и статику SPA из `wwwroot` (fallback на `index.html`). Один origin, база API — относительный `/api`. - Multi-stage Dockerfile: node (фронт) → dotnet sdk (publish + копирование в `wwwroot`) → aspnet - runtime. Никакого gosu/root-drop и Data Protection key-ring — секретов на диске пока нет - (JWT-ключ — обычная конфигурация, не шифруемый секрет at-rest); если появятся зашифрованные - данные (например API-ключи внешних сервисов) — добавлять Data Protection по образцу PnvPanel. + runtime. Контейнер работает под непривилегированным пользователем образа (`USER $APP_UID`, + uid/gid 1654) — но **статически**, без gosu/entrypoint-скриптов и подгонки uid на старте: права + на bind-mount хранилища выставляет оператор на хосте (см. `docs/server-storage-setup.md` § 5). + Data Protection key-ring не заводим — секретов на диске пока нет (JWT-ключ — обычная конфигурация, + не шифруемый секрет at-rest); появятся зашифрованные данные (например API-ключи внешних + сервисов) — добавлять Data Protection по образцу PnvPanel. - docker-compose: только `app` — PostgreSQL живёт вне compose (внешний сервер/хост, адрес и креды — в `ConnectionStrings__Default` из `.env`; база и пользователь на нём создаются заранее вручную, приложение их не сидит). Миграции применяются авто на старте diff --git a/Dockerfile b/Dockerfile index 705a372..0689a75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,9 @@ FROM node:22-alpine AS frontend WORKDIR /app/frontend RUN corepack enable COPY frontend/package.json frontend/pnpm-lock.yaml ./ -RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile +# --ignore-scripts: ни один пакет фронта не требует postinstall, поэтому lifecycle-скрипты зависимостей +# в сборке не выполняются — компрометация любого пакета в дереве не даёт исполнения кода на этапе install. +RUN corepack prepare pnpm@11.9.0 --activate && pnpm install --frozen-lockfile --ignore-scripts COPY frontend/ ./ RUN pnpm build @@ -34,6 +36,11 @@ ENV ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_HTTP_PORTS=8080 EXPOSE 8080 COPY --from=backend /app/publish ./ +# Работаем под непривилегированным пользователем образа aspnet (app, uid/gid 1654 — $APP_UID задан +# в самом образе). Порт 8080 непривилегированный, установка пакетов уже позади, приложению нужна +# только запись в /media. ВАЖНО: bind-mount пробрасывает права хоста как есть — каталог хранилища +# должен принадлежать uid 1654, иначе контейнер не сможет писать (см. docs/server-storage-setup.md § 5). +USER $APP_UID HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \ CMD curl -f http://localhost:8080/health || exit 1 ENTRYPOINT ["dotnet", "TeleWave.Api.dll"] diff --git a/README.md b/README.md index 581f937..035e73a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ docker compose up -d --build # → http://localhost:8085 (админ — логин/пароль из .env, AdminSeed__Username/Password) ``` +**Права на медиахранилище.** Контейнер работает под непривилегированным пользователем (uid/gid +`1654`), а bind-mount пробрасывает права хоста как есть — каталог хранилища должен принадлежать +этому uid, иначе приложение стартует, но любая запись в `/media` упадёт с `Permission denied`. +Полная процедура (включая группу, через которую вы сами кладёте файлы в `inbox/` и `manual/`) — +[`docs/server-storage-setup.md`](docs/server-storage-setup.md), шаг 5: + +```bash +sudo groupadd -g 1654 telewave +sudo usermod -aG telewave "$USER" # затем перелогиниться +sudo chown -R 1654:1654 /srv/telewave/media +sudo chmod -R 750 /srv/telewave/media +sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual +``` + +> Обновляетесь с версии, где контейнер работал под root? Это и есть вся миграция: остановите +> контейнер (`docker compose down`), выполните команды выше, поднимайте новый образ. + ## Локальная разработка ```bash diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index f2c1d33..6af816e 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -1,5 +1,4 @@ using System.Text; -using System.Text.RegularExpressions; using LiteCqrs; using Microsoft.Extensions.Options; using TeleWave.Api.Common; @@ -19,8 +18,6 @@ namespace TeleWave.Api.Endpoints; public static class MediaEndpoints { - private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled); - public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) { var admin = app.MapGroup("/api/admin/media") @@ -177,16 +174,7 @@ public static class MediaEndpoints /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) { - string indexPath; - try - { - indexPath = paths.SegmentPath(id, "index.m3u8"); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(indexPath)) + if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath) return Results.NotFound(); var baseUrl = $"/api/admin/media/{id}/preview/"; @@ -206,19 +194,10 @@ public static class MediaEndpoints private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) { - if (!SegmentFileName.IsMatch(file)) + if (!SegmentFiles.IsSegmentName(file)) return Results.NotFound(); - string path; - try - { - path = paths.SegmentPath(id, file); - } - catch (UnauthorizedAccessException) - { - return Results.NotFound(); - } - if (!File.Exists(path)) + if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path) return Results.NotFound(); return Results.File(path, "video/mp2t", enableRangeProcessing: true); diff --git a/backend/src/TeleWave.Api/appsettings.Development.json b/backend/src/TeleWave.Api/appsettings.Development.json index 09b30c4..60a2cdf 100644 --- a/backend/src/TeleWave.Api/appsettings.Development.json +++ b/backend/src/TeleWave.Api/appsettings.Development.json @@ -1,4 +1,10 @@ { + // Локальная БД разработчика. В appsettings.json строки подключения нет намеренно: этот файл + // едет в образ, и креды в нём (даже заведомо игрушечные) — это и находка сканера, и приглашение + // однажды поправить их «на месте» вместо ConnectionStrings__Default из окружения. + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/backend/src/TeleWave.Api/appsettings.json b/backend/src/TeleWave.Api/appsettings.json index 8ac69ee..f7db417 100644 --- a/backend/src/TeleWave.Api/appsettings.json +++ b/backend/src/TeleWave.Api/appsettings.json @@ -1,7 +1,4 @@ { - "ConnectionStrings": { - "Default": "Host=localhost;Port=5432;Database=telewave;Username=telewave;Password=telewave" - }, "Jwt": { "Issuer": "TeleWave", "Audience": "TeleWave", diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs index 881061b..9dc4784 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPreview.cs @@ -3,10 +3,16 @@ using System.Security.Cryptography; namespace TeleWave.Application.Broadcast.Bumpers; /// -/// Детерминированный id ассета-превью для блока заставки: один и тот же на каждый повторный рендер, +/// Детерминированный id ассета-превью для подблока заставки: один и тот же на каждый повторный рендер, /// поэтому предпросмотр перезаписывает единственный каталог assets/{id}, а не плодит новые. /// public static class BumperPreview { - public static Guid AssetId(Guid templateId) => new(MD5.HashData(templateId.ToByteArray())); + /// + /// Свёртка id подблока в стабильный id превью. Не защита — просто способ получить из одного GUID + /// другой, воспроизводимо; берём SHA-256 и первые 16 байт, чтобы в коде не оставалось вызовов + /// сломанных хеш-функций, которые потом приходится каждый раз объяснять сканерам. + /// + public static Guid AssetId(Guid variantId) => + new(SHA256.HashData(variantId.ToByteArray()).AsSpan(0, 16)); } diff --git a/backend/src/TeleWave.Application/Library/EpisodeName.cs b/backend/src/TeleWave.Application/Library/EpisodeName.cs index 0129e75..93dcef5 100644 --- a/backend/src/TeleWave.Application/Library/EpisodeName.cs +++ b/backend/src/TeleWave.Application/Library/EpisodeName.cs @@ -7,20 +7,28 @@ namespace TeleWave.Application.Library; /// вытаскиваются из имён по мере надобности (сводка в списке шоу, метка в расписании админки). public static class EpisodeName { + /// Потолок на один Match. Шаблоны ниже линейные и в катастрофический бэктрекинг не уходят, + /// но имя файла приходит извне (загрузка, inbox), а разбор идёт в цикле по всей библиотеке — + /// страховка стоит ноль, а её отсутствие превращает любую будущую правку шаблона в риск. + private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1); + private static readonly Regex SxxEyy = new( @"[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})", - RegexOptions.Compiled + RegexOptions.Compiled, + MatchTimeout ); private static readonly Regex NxNN = new( @"(?:^|[^\d])(\d{1,2})x(\d{1,3})", - RegexOptions.Compiled | RegexOptions.IgnoreCase + RegexOptions.Compiled | RegexOptions.IgnoreCase, + MatchTimeout ); // Ведущий номер серии: «01. Название», «02 - Название», «03_Название» — сезон считаем первым. private static readonly Regex LeadingNumber = new( @"^\s*(\d{1,3})[\s._)\]-]", - RegexOptions.Compiled + RegexOptions.Compiled, + MatchTimeout ); public static (int Season, int Episode)? Parse(string? name) diff --git a/docs/server-storage-setup.md b/docs/server-storage-setup.md index 442a719..0e1f5bf 100644 --- a/docs/server-storage-setup.md +++ b/docs/server-storage-setup.md @@ -8,7 +8,7 @@ Runbook: разметка, форматирование и монтирован > разделов), но каждый шаг ниже содержит проверку — не пропускайте их. Итог: раздел `sdb1` (ext4) смонтирован в `/srv/telewave/media`, внутри созданы рабочие каталоги, -запись из контейнера (сейчас работает под root) возможна. +запись из контейнера (работает под непривилегированным uid 1654) возможна. --- @@ -106,12 +106,28 @@ findmnt /srv/telewave/media sudo mkdir -p /srv/telewave/media/{inbox,manual,uploads,originals,assets} ``` -**Владелец.** Контейнер сейчас работает под `root` (в Dockerfile нет `USER`), а bind-mount -пробрасывает права хоста внутрь как есть. Поэтому достаточно оставить владельцем root: +**Владелец.** Контейнер работает под непривилегированным пользователем `app` образа aspnet — +**uid/gid 1654** (`USER $APP_UID` в Dockerfile). Bind-mount пробрасывает права хоста внутрь как +есть, никакого маппинга uid не происходит: контейнер увидит ровно те номера, что стоят на хосте. +Значит, владельцем хранилища должен быть 1654. + +Имени `app` на хосте нет — заводим группу с этим gid, чтобы права были читаемыми в `ls` и чтобы +в неё можно было добавить себя: ```bash -sudo chown -R root:root /srv/telewave/media -sudo chmod -R 755 /srv/telewave/media +sudo groupadd -g 1654 telewave # «уже существует» — не ошибка, идём дальше +sudo usermod -aG telewave "$USER" # чтобы класть файлы в inbox/manual под собой +``` + +> Членство в группе применяется **только к новым сессиям**: перелогиньтесь (или `newgrp telewave`), +> иначе следующая команда отработает, а записать файл вы всё равно не сможете. Проверка — `id` +> должен показать `telewave` в списке групп. + +Теперь владелец и базовые права: + +```bash +sudo chown -R 1654:1654 /srv/telewave/media +sudo chmod -R 750 /srv/telewave/media ``` Два каталога наполняете вы, а не приложение: @@ -122,17 +138,20 @@ sudo chmod -R 755 /srv/telewave/media (Медиа → «Из папки manual»), выбираются галочками и сразу привязываются к шоу. Импортированные файлы уходят из каталога так же, как из `inbox/`. -Чтобы класть в них файлы вручную (SFTP/rsync) под своим пользователем, откройте на запись именно -эти каталоги вашей группе: +Их открываем группе на запись, плюс setgid (`2` в начале режима) — чтобы файлы, положенные вами по +SFTP/rsync, наследовали группу `telewave`, а не вашу личную, и приложение их видело: ```bash -sudo chown root:$(id -gn) /srv/telewave/media/inbox /srv/telewave/media/manual -sudo chmod 775 /srv/telewave/media/inbox /srv/telewave/media/manual +sudo chmod 2775 /srv/telewave/media/inbox /srv/telewave/media/manual ``` -> При переходе контейнера на non-root пользователя (если позже добавим `USER` в Dockerfile — -> в aspnet-образе это обычно uid `1654`), сменить владельца на этот uid: -> `sudo chown -R 1654:1654 /srv/telewave/media` (кроме `inbox` и `manual`, оставленных вам). +Забрать файл из каталога приложение сможет в любом случае: удаление зависит от прав на **каталог** +(владелец — 1654), а не на сам файл, поэтому чужой umask у ваших загрузок импорту не мешает. + +**Обновление существующей установки.** Если хранилище было заведено раньше, когда контейнер работал +под root, — те же команды и есть вся миграция: выполните их на остановленном контейнере +(`docker compose down`), затем поднимайте новый образ. До смены владельца приложение стартует, но +любая запись в `/media` будет падать с `Permission denied`. --- @@ -185,14 +204,24 @@ Docker — это важно, потому что файлы в `inbox/` кла ## 7. Проверка записи из контейнера -После добавления тома и пересборки образа убедиться, что контейнер пишет на диск: +После добавления тома и пересборки образа убедиться, что контейнер работает под нужным uid и пишет +на диск: ```bash +docker compose exec app id # ожидается uid=1654 gid=1654 docker compose exec app sh -c 'touch /media/.wtest && ls -l /media/.wtest && rm /media/.wtest' ``` -Команда должна отработать без ошибок доступа. На этом подготовка хоста завершена — дальнейшее -(создание `MediaAsset`, нарезка ffmpeg) делает уже само приложение. +И что вы сами можете класть файлы вручную (под своим пользователем, не через sudo): + +```bash +touch /srv/telewave/media/inbox/.wtest && rm /srv/telewave/media/inbox/.wtest +``` + +Обе команды должны отработать без ошибок доступа. `Permission denied` в первой — не сделан +`chown -R 1654:1654` из шага 5; во второй — вы не в группе `telewave` либо не перелогинились после +`usermod`. На этом подготовка хоста завершена — дальнейшее (создание `MediaAsset`, нарезка ffmpeg) +делает уже само приложение. ---