diff --git a/.gitignore b/.gitignore index 164c49c..ba0f954 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Секреты config/passwd +config/secrets.env # токены ботов и mutations_api_secret +config/init.custom.conf # боевой конфиг со secrets (собирается config-secrets.py) # Локальные данные и кеш (только корневые каталоги; якорь «/» обязателен — # иначе «cache/» игнорирует и mods/Music/Services/Cache/ и т.п.) @@ -14,3 +16,12 @@ config/passwd # Серверный override (Proxmox: том DLNA -> /mnt/torrents), живёт только на сервере docker-compose.override.yml + +# Секреты (не коммитить — см. README, раздел «Секреты») +config/passwd +config/secrets.env +config/init.custom.conf + +# Веб-клиент Lampa (качается LampaCron с github.com, нет в образе, +# пропадает при пересоздании контейнера; см. scripts/restore-lampa-main.sh) +wwwroot/lampa-main/ diff --git a/AGENT.md b/AGENT.md index db7c633..14f0cd6 100644 --- a/AGENT.md +++ b/AGENT.md @@ -30,6 +30,8 @@ docker cp lampac:/lampac/module/<Имя> "mods/<Имя>" Сейчас в `mods/`: `AdminPanel`, `Tracks`, `Tg-notify.bot`, `TelegramAuth`, `TelegramAuthBot`. **`mods/Music` удалён** (remote-коммит `remove Music`): в образе у Music `manifest.enable: false`, поэтому модуль не грузится, `/music.js` → 404, секция `"Music"` и плагин убраны из `init.conf`. `mods/WatchTogether` тоже удалён (вернуть — той же механикой, он в списке «ещё можно включить» в README). +**Веб-клиент `lampa-main` — bind-mount из `./lampa-main/`** (в .gitignore). В образе его нет, `LampaCron` качает с GitHub; при пересоздании контейнера без mount клиент исчезает. См. `scripts/restore-lampa-main.sh`. + ### 2. Механика загрузки модуля (4 независимых фильтра) Модуль грузится, только если выполнены **все**: @@ -116,12 +118,12 @@ docker exec lampac sh -c '...' # осмотр состояния в конте `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`, `bot_token` в `TelegramBot`, `bot_token` в `TelegramAuthBot`, `mutations_api_secret`. Маршруты `/adminpanel`, `/weblog`, `Tracks`, `Catalog`, `DLNA`, proxy-модули — только для доверенной сети без WAF не открывать. -**Секреты в git не попадают:** в закоммиченном `config/init.conf` токены и секрет должны быть **пустыми** (так делает и remote — коммит `remove`), реальные значения живут только в рабочем файле на сервере (копия до мержа — `/root/init.conf.local`). Перед `git add config/init.conf`: `git diff config/init.conf | grep -E ":[0-9]{8,}:AA|\"mutations_api_secret\": \"[a-f0-9]{16}"` → должно быть пусто. +**Секреты:** в git `config/init.conf` — секреты **пустые**. Реальные значения — в `config/secrets.env` (gitignored), боевой конфиг — `config/init.custom.conf` (gitignored). Собирается скриптом: `python3 scripts/config-secrets.py apply`. Перед коммитом: `python3 scripts/config-secrets.py check` (должен выдать `секретов в config/init.conf нет — ok`). После `git pull`/смены ветки `apply` запускается автоматически через git-хуки (`scripts/install-git-hooks.sh`). ### 11. Tg-notify.bot (Telegram-бот) - Механика: long polling по `TelegramBot.bot_token`; если токен пуст — polling не стартует (в логе `[TelegramBot] Установите bot_token…`, сервер работает). После правки секции: `docker compose restart lampac`. -- **Токен только локально:** в git поле пустое (правило 10). На сервере в `init.conf` вставлен `@KlinkerZabbixBot` — после `git pull`/переписывания конфига проверять, что polling снова стартовал (`[TelegramBot] @KlinkerZabbixBot запущен!`). Второй экземпляр на том же токене даёт `Conflict: terminated by other getUpdates request`. +- **Токен только локально:** в git `config/init.conf` поле пустое (правило 10). Реальный токен живёт в `config/secrets.env`, а `docker-compose.yaml` монтирует `config/init.custom.conf` (собрать: `python3 scripts/config-secrets.py apply`). После `git pull` хук `post-merge` делает `apply` сам; если нет хуков — выполнить вручную. Второй экземпляр на том же токене даёт `Conflict: terminated by other getUpdates request`. - Три подсистемы: трекер серий (Trakt→TMDB), трекер озвучек (параллельно Mirage/Collaps/VideoHub через `lampac_host`: `/lite/mirage`, `/lite/collaps`, `/lite/cdnvideohub`), клиентский плагин `tg-notify.js` (вкладка «TG Подписки», перехват 🔔). Полный разбор — в README.md. - Данные подписок: `database/tgnotify/{users.json,subscriptions.json}` (том `./database/`, в .gitignore). Не «чинить» вручную — это живые данные бота. - Диагностика: `GET /tg-notify.js` (200 без авторизации), `GET /api/tg/voices` (200), `/api/tg/*` остальной — требует `user_uid`. Озвучки могут не работать, если балансеры недоступны (проверить `GET /lite/mirage` — тело `disable`/ошибка, `/lite/collaps` — 503); тогда уведомления приходят только о выходе серий. diff --git a/README.md b/README.md index b3d2791..12ca73d 100644 --- a/README.md +++ b/README.md @@ -64,22 +64,61 @@ ls -ln /var/lib/docker/volumes/lampac_database/_data/Sisi.sql # uid/gid 1000 Проверить uid процесса в контейнере: `docker exec lampac id` → `uid=1000(lampac)`. -Правило: **любой bind-mount файл/каталог, в который пишет контейнер, должен принадлежать `1000:1000`** (см. `.gitignore` для `config/passwd`, `dlna/`). +Правило: **любой bind-mount файл/каталог, в который пишет контейнер, должен принадлежать `1000:1000`** (см. `.gitignore` для `config/passwd`, `dlna/`, `lampa-main/`). + +## Секреты (вынесены из git) + +Модули `TelegramBot`, `TelegramAuthBot`, `TelegramAuth` читают `bot_token` и `mutations_api_secret` из конфига. В репозитории эти поля **всегда пустые** — реальные значения живут в файле, который не коммитится. + +| Файл | В git? | Назначение | +|---|---|---| +| `config/init.conf` | ✅ | Шаблон конфига, секреты **пустые**. Именно его редактируете для обычных настроек (`listen`, `SkipModules`, `initPlugins`, модули). | +| `config/secrets.env` | ❌ .gitignore | Токены ботов и `mutations_api_secret`. `TELEGRAM_NOTIFY_BOT_TOKEN`, `TELEGRAM_AUTH_BOT_TOKEN`, `TELEGRAM_AUTH_MUTATIONS_SECRET`. | +| `config/init.custom.conf` | ❌ .gitignore | Боевой конфиг, который монтируется в контейнер. Собирается из шаблона + `secrets.env` скриптом. | +| `scripts/config-secrets.py` | ✅ | Собирает/разбирает боевой конфиг: `apply`, `export`, `check`, `status`. | +| `scripts/install-git-hooks.sh` | ✅ | Устанавливает хуки: `post-merge`/`post-checkout` → `apply`, `pre-commit` → `check`. | +| `docker-compose.yaml` | ✅ | Монтирует `./config/init.custom.conf:/lampac/init.conf` (не `init.conf`). | + +**Рабочий процесс:** + +```bash +# первый раз / после git pull: +python3 scripts/config-secrets.py apply # init.custom.conf = шаблон + secrets.env +docker compose up -d # рекреация из-за нового mount + +# правки в init.conf: +# 1) правите config/init.conf (шаблон) +# 2) python3 scripts/config-secrets.py apply -> обновляет init.custom.conf +# 3) docker compose restart lampac + +# перед коммитом: +python3 scripts/config-secrets.py export # секреты -> пустые в шаблоне +git add config/init.conf +git commit -m "..." +``` + +**Git-хуки (рекомендуется):** `bash scripts/install-git-hooks.sh` — после `git pull`/смены ветки `apply` запустится сам, а `pre-commit` не даст случайно закоммитить секреты. + +> `docker-compose.override.yml` (в .gitignore) — серверный override для Proxmox (том DLNA -> `/mnt/torrents`), живёт только на сервере. ## Структура проекта | Путь | Назначение | |---|---| -| `docker-compose.yaml` | Основной сервис (образ закреплён на `ghcr.io/lampac-nextgen/lampac:1.68.0`) | -| `config/init.conf` | Конфигурация сервера (JSON, монтируется в `/lampac/init.conf`) | -| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (не в git) | -| volume `lampac_cache` | Кеш (`/lampac/cache`) — Docker named volume, не файловый том | +| `docker-compose.yaml` | Основной сервис (образ `ghcr.io/lampac-nextgen/lampac:1.68.1`) | +| `docker-compose.override.yml` | Серверный override (Proxmox: том DLNA -> `/mnt/torrents`), в .gitignore | +| `config/init.conf` | Шаблон конфига (JSON, в git, секреты **пустые**) | +| `config/init.custom.conf` | Боевой конфиг (в .gitignore, монтируется в `/lampac/init.conf`) | +| `config/secrets.env` | Токены ботов и `mutations_api_secret` (в .gitignore) | +| `config/passwd` | Пароль root для `/adminpanel` и `/weblog` (в .gitignore) | +| `lampa-main/` | Веб-клиент Lampa (bind-mount в `/lampac/wwwroot/lampa-main`, в .gitignore, качается LampaCron) | +| volume `lampac_cache` | Кеш (`/lampac/cache`) — Docker named volume | | volume `lampac_database` | SQLite-базы Sync, TimeCode, tg-notify, tracks, storage (`/lampac/database`) — Docker named volume | | `dlna/` | Локальные медиа для DLNA (`/lampac/data/dlna`) | | `plugins/extensions.json` | Каталог плагинов для веб-клиента (раздел «Плагины»), отдаётся через `/extensions` | | `mods/<Имя>/` | Пользовательские копии встроенных модулей с `enable: true` | -> `docker-compose.override.yml` (в .gitignore) — серверное override для Proxmox (том DLNA -> `/mnt/torrents`), живёт только на сервере. +> Веб-клиент `lampa-main` **нет в образе**: `LampaCron` качает его с GitHub при старте, и он живёт только в writable-слое контейнера. При пересоздании контейнера (`docker compose up -d`) клиент исчезает, если не смонтирован как bind-mount. См. раздел «Секреты» и `scripts/restore-lampa-main.sh`. ## Включённые модули из `mods/` @@ -287,7 +326,9 @@ docker compose pull docker compose up -d ``` -Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`. +Перед обновлением проверить, что в `docker-compose.yaml` раскомментированы нужные тома. Данные сохраняются в примонтированных томах: `config/`, `cache/`, `database/`, `dlna/`, `mods/`, `lampa-main/`. + +`wwwroot/lampa-main` смонтирован как bind-mount из `./lampa-main/` (см. раздел «Структура проекта»), поэтому переживает рекреацию контейнера. Если директория на хосте пуста — `bash scripts/restore-lampa-main.sh scripts/lampa-main.zip` (архив берётся с https://github.com/yumata/lampa/archive/refs/heads/main.zip). **Образ закреплён точным digest** (`1.68.0@sha256:752dafd8…`): тег `1.68.0` на ghcr — mutable, его переиздавали, и в новой сборке **копии модулей в `mods/`** (Roslyn компилирует их на каждом старте) перестают собираться — `CS0103` по символам из старого билда и crash-loop сервера. Чтобы обновить образ: взять новый digest, пересобрать копии модулей в `mods/` из нового образа (`docker cp lampac:/lampac/module/<Имя> mods/<Имя>` + вернуть `"enable": true`) и заменить digest в `docker-compose.yaml`. Не делать «просто» `docker compose pull` без фиксации digest. @@ -297,6 +338,6 @@ docker compose up -d - Не публикуй в интернет без аутентификации, WAF и ограничения сети: `/adminpanel`, `/weblog`, `/database-editor`, `Tracks`, `Transcoding`, `Catalog`, `DLNA`, proxy-модули. - Нигде не раскрывай `config/passwd`, cookie `accspasswd`, заголовок `lcrqpasswd`. -- **Секреты в `config/init.conf` не коммитятся**: `TelegramBot.bot_token`, `TelegramAuthBot.bot_token`, `mutations_api_secret`. В git они должны быть пустыми — реальные значения живут только в файле на сервере (`/root/init.conf.local` — резервная копия перед мержем). Перед `git add config/init.conf` проверять `git diff` на токены (`git diff config/init.conf | grep -E ":[0-9]{8,}:AA"` → пусто). +- **Секреты в git не попадают**: `TelegramBot.bot_token`, `TelegramAuthBot.bot_token`, `mutations_api_secret` хранятся в `config/secrets.env` (в .gitignore). В git `config/init.conf` эти поля всегда пустые. Реальный боевой конфиг — `config/init.custom.conf` (тоже gitignored), собирается скриптом `scripts/config-secrets.py apply` из шаблона + секретов. Перед `git add config/init.conf`: `python3 scripts/config-secrets.py check` → должно быть `check: секретов в config/init.conf нет — ок`. - Для публикации за reverse proxy: [deployment/reverse-proxy](https://docs.lampac.dev/deployment/reverse-proxy) (HTTPS в ответах, реальный IP клиента, WebSocket, SSE, streaming). - `shm_size: 4096mb` — рекомендованный минимум для Playwright/Chromium. diff --git a/docker-compose.yaml b/docker-compose.yaml index 2f34c24..d749c8c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,13 +11,14 @@ services: shm_size: 4096mb volumes: - ./config/passwd:/lampac/passwd - - ./config/init.conf:/lampac/init.conf + - ./config/init.custom.conf:/lampac/init.conf - ./plugins/extensions.json:/lampac/module/LampaWeb/plugins/extensions.json - ./plugins:/lampac/wwwroot/plugins - ./mods:/lampac/mods - cache:/lampac/cache - database:/lampac/database - ./dlna:/lampac/data/dlna + - ./lampa-main:/lampac/wwwroot/lampa-main volumes: cache: diff --git a/scripts/config-secrets.py b/scripts/config-secrets.py new file mode 100644 index 0000000..d77f786 --- /dev/null +++ b/scripts/config-secrets.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Вынос секретов Lampac из git: шаблон config/init.conf <-> боевой config/init.custom.conf. + +В git (config/init.conf) секреты всегда ПУСТЫЕ — это делает рабочее дерево чистым, +поэтому git pull/merge никогда не конфликтует и секреты не уезжают в remote. +Для сервера собирается config/init.custom.conf (шаблон + config/secrets.env), +он монтируется в контейнер вместо init.conf (см. docker-compose.yaml) и +в .gitignore. + +Команды: + apply config/init.conf + config/secrets.env -> config/init.custom.conf (после git pull) + export config/init.custom.conf -> config/init.conf, секреты обнуляются (перед git add) + check проверяет, что в config/init.conf (или в staged-версии) нет секретов + status показывает, что где лежит + +Обычно руками ничего не нужно: scripts/install-git-hooks.sh вешает +post-merge/post-checkout на apply и pre-commit на check. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMPLATE = os.path.join(ROOT, "config", "init.conf") +LIVE = os.path.join(ROOT, "config", "init.custom.conf") +SECRETS = os.path.join(ROOT, "config", "secrets.env") + +# (секция, поле) в init.conf -> ключ в config/secrets.env +MAP = [ + (("TelegramBot", "bot_token"), "TELEGRAM_NOTIFY_BOT_TOKEN"), + (("TelegramAuthBot", "bot_token"), "TELEGRAM_AUTH_BOT_TOKEN"), + (("TelegramAuth", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"), + (("TelegramAuthBot", "mutations_api_secret"), "TELEGRAM_AUTH_MUTATIONS_SECRET"), +] + + +def load_secrets() -> dict[str, str] | None: + """None, если secrets.env отсутствует (тогда apply просто предупреждает).""" + if not os.path.exists(SECRETS): + return None + out: dict[str, str] = {} + for line in open(SECRETS, encoding="utf-8"): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def load_json(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def save_json(path: str, data: dict) -> None: + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + os.replace(tmp, path) + + +def cmd_apply() -> None: + """Шаблон + секреты -> боевой файл, который монтируется в контейнер.""" + tpl = load_json(TEMPLATE) + sec = load_secrets() + if sec is None: + print(f"apply: нет {os.path.relpath(SECRETS, ROOT)} — " + f"{os.path.relpath(LIVE, ROOT)} не создан (секреты останутся пустыми)") + return + missing = [] + for (section, field), key in MAP: + value = sec.get(key, "") + if not value: + missing.append(key) + if section in tpl: + tpl[section][field] = value + save_json(LIVE, tpl) + # контейнер пишет в боевой конфиг через AdminPanel (uid 1000) + try: + os.chown(LIVE, 1000, 1000) + except PermissionError: + pass + os.chmod(LIVE, 0o644) + print(f"apply: {os.path.relpath(LIVE, ROOT)} собран из шаблона + secrets.env") + if missing: + print(f" внимание: нет значения для {', '.join(sorted(set(missing)))} — поле останется пустым") + + +def cmd_export() -> None: + """Боевой файл -> шаблон, секреты обнуляются (для git add).""" + if not os.path.exists(LIVE): + print(f"export: {os.path.relpath(LIVE, ROOT)} нет — нечего экспортировать") + return + live = load_json(LIVE) + changed = 0 + for (section, field), _ in MAP: + if section in live and live.get(section, {}).get(field): + live[section][field] = "" + changed += 1 + save_json(TEMPLATE, live) + print(f"export: шаблон обновлён из боевого конфига, секретов обнулено: {changed}") + + +def read_template_for_check(staged: bool) -> str: + if staged: + try: + return subprocess.check_output( + ["git", "show", ":config/init.conf"], cwd=ROOT, text=True + ) + except subprocess.CalledProcessError: + sys.exit("check: config/init.conf не в индексе") + return open(TEMPLATE, encoding="utf-8").read() + + +def cmd_check(staged: bool = False) -> None: + """Падает (exit 1), если в шаблоне есть непустой секрет.""" + data = json.loads(read_template_for_check(staged)) + found = [] + for (section, field), key in MAP: + value = (data.get(section) or {}).get(field) + if value: + found.append(f"{section}.{field} ({key}) = {value[:6]}…") + if found: + sys.exit( + "check: ОТКАЗ — в config/init.conf есть секреты, коммитить нельзя:\n " + + "\n ".join(found) + + "\nВыполните: python3 scripts/config-secrets.py export" + ) + print("check: секретов в config/init.conf нет — ок") + + +def cmd_status() -> None: + tpl = load_json(TEMPLATE) + live = load_json(LIVE) if os.path.exists(LIVE) else None + print(f"{'путь':28} {'есть?' :6} секреты") + for (section, field), key in MAP: + t = (tpl.get(section) or {}).get(field) + l = (live.get(section) or {}).get(field) if live else None + print( + f"{section}.{field:24} шаблон={'заполнен' if t else 'пусто':8} " + f"боевой={'заполнен' if l else ('нет файла' if live is None else 'пусто')}" + ) + print(f"secrets.env: {'есть' if os.path.exists(SECRETS) else 'НЕТ'}") + + +def main() -> None: + args = sys.argv[1:] + cmd = args[0] if args else "status" + if cmd == "apply": + cmd_apply() + elif cmd == "export": + cmd_export() + elif cmd == "check": + cmd_check(staged="--staged" in args) + elif cmd == "status": + cmd_status() + else: + sys.exit(__doc__) + + +if __name__ == "__main__": + main() diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 0000000..235653d --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Вешает git-хуки, которые держат секреты вне git. +# post-merge / post-checkout -> apply (собирает config/init.custom.conf) +# pre-commit -> check (отменяет коммит с секретами) +set -e +cd "$(git rev-parse --show-toplevel)" +mkdir -p .git/hooks + +cat > .git/hooks/post-merge <<'H' +#!/bin/sh +exec python3 scripts/config-secrets.py apply +H + +cat > .git/hooks/post-checkout <<'H' +#!/bin/sh +exec python3 scripts/config-secrets.py apply +H + +cat > .git/hooks/pre-commit <<'H' +#!/bin/sh +exec python3 scripts/config-secrets.py check --staged +H + +chmod +x .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit +echo "хуки установлены:" +ls -l .git/hooks/post-merge .git/hooks/post-checkout .git/hooks/pre-commit \ No newline at end of file diff --git a/scripts/restore-lampa-main.sh b/scripts/restore-lampa-main.sh new file mode 100644 index 0000000..9f44154 --- /dev/null +++ b/scripts/restore-lampa-main.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Восстанавливает веб-клиент Lampa (wwwroot/lampa-main), который LampaCron +# берёт с github.com и который пропадает при пересоздании контейнера +# (нет в образе, живёт только в writable-слое). +# +# bash scripts/restore-lampa-main.sh scripts/lampa-main.zip +set -e +cd "$(git rev-parse --show-toplevel)" +ZIP="${1:-scripts/lampa-main.zip}" +if [ ! -f "$ZIP" ]; then + echo "нет $ZIP — берите архив с https://github.com/yumata/lampa/archive/refs/heads/main.zip" + exit 1 +fi + +docker exec lampac sh -c "rm -rf /lampac/wwwroot/lampa-main /lampac/wwwroot/lampa.zip" +docker cp "$ZIP" lampac:/lampac/wwwroot/lampa.zip + +docker exec lampac sh -c ' + cd /lampac/wwwroot + unzip -q -o lampa-main.zip + mv lampa-main-main lampa-main 2>/dev/null || mv lampa-main lampa-main 2>/dev/null || true + chown -R 1000:1000 lampa-main + rm -f lampa.zip + # инъекция lampainit.js (делает LampaCron при следующем старте) + python3 - <", "") + open(p,"w",encoding="utf-8").write(html) + print("index.html: lampainit.js внедрён") +else: + print("index.html: уже был") +PY +' + +echo "lampa-main восстановлен ($(docker exec lampac sh -c 'find /lampac/wwwroot/lampa-main -type f | wc -l') файлов)" \ No newline at end of file