<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ru"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://3draven.github.io/blog/feed.xml" rel="self" type="application/atom+xml" /><link href="https://3draven.github.io/blog/" rel="alternate" type="text/html" hreflang="ru" /><updated>2026-10-15T19:41:31+00:00</updated><id>https://3draven.github.io/blog/feed.xml</id><title type="html">Блог 3DRaven</title><subtitle>Мой бложик, перенесённый сюда с Хабра</subtitle><entry><title type="html">Трекер задач, в котором работают ИИ: TaskForge, Obsidian, Syncthing и пара ассистентов</title><link href="https://3draven.github.io/blog/ai/selfhosted/2026/10/15/taskforge-ai-tracker.html" rel="alternate" type="text/html" title="Трекер задач, в котором работают ИИ: TaskForge, Obsidian, Syncthing и пара ассистентов" /><published>2026-10-15T16:00:00+00:00</published><updated>2026-10-15T16:00:00+00:00</updated><id>https://3draven.github.io/blog/ai/selfhosted/2026/10/15/taskforge-ai-tracker</id><content type="html" xml:base="https://3draven.github.io/blog/ai/selfhosted/2026/10/15/taskforge-ai-tracker.html"><![CDATA[<p><img src="/blog/assets/images/taskforge-cover.png" alt="TaskForge, Obsidian, Syncthing и ИИ-ассистенты" /></p>

<p>В прошлых статьях я переносил домой музыку и фотографии. Эта статья про другое: сделан свой трекер задач и пустил туда работать ИИ. Задачи живут в обычных Markdown-файлах, по одному файлу на задачу. Синхронизируются они между телефоном, ноутбуком и Linux-боксом. Читают и пишут их Android-приложение TaskForge, Obsidian, CLI, MCP-серверы и два вида ИИ-ассистентов: «Admin» (Grok Bot) на боксе и проектные сессии Claude на ноутбуке. Ниже рассказываю, как это устроено, какие правила пришлось ввести и где я наступил на грабли.</p>

<h3 id="с-чего-всё-началось">С чего всё началось</h3>

<p>Всё началось с Google Таблиц. У каждого проекта была своя таблица-трекер: строки задач, вкладка с решениями, вкладка с вопросами. Рядом в репозиториях лежали <code class="language-plaintext highlighter-rouge">PLAN.md</code>, <code class="language-plaintext highlighter-rouge">PROGRESS.md</code>, <code class="language-plaintext highlighter-rouge">DECISIONS.md</code> и <code class="language-plaintext highlighter-rouge">QUESTIONS.md</code>. Пока агент был единственным, кто их вёл, это кое-как работало.</p>

<p>Потом работу проектов стали делать ИИ. Claude гонял замеры и писал код, Grok Bot следил за процессами и собирал отчёты. Обоим нужно было где-то записывать, что сделано, что решено и что ждёт меня. Результаты начали расползаться: часть оставалась в чате, часть в <code class="language-plaintext highlighter-rouge">PROGRESS.md</code>, часть в таблице, а часть только в контексте сессии, которая потом закрывалась и забывала всё. Я поймал себя на том, что по утрам выясняю у одного ИИ, о чём вчера договорился с другим, а склероз которым тотально страдают нейронки вообще приводил к непрерывным открытиям, что в плане двухнедельной давности половина не сделана.</p>

<p>Мне был нужен трекер, который одинаково удобен трём участникам. Мне на телефоне, причём с телефона я в основном и управляю. ИИ через нормальный программный интерфейс, а не через скриншоты веб-формы. И ему самому, чтобы трекер пережил любой из своих клиентов. Обычные Markdown-файлы под это подходят. TaskForge оказался приложением, которое умеет работать с такими файлами на телефоне и при этом даёт CLI и MCP-сервер для ИИ.</p>

<p>Трекер, в который пишут несколько ИИ, — это добровольное назначение себя администратором распределённой системы. Синхронизация файлов между тремя устройствами, на которых одновременно что-то правят, поломается обязательно. Поэтому половина статьи не про «как поставить приложение», а про правила, валидацию и сторожей, без которых всё это разваливается за пару дней.</p>

<h3 id="чего-мы-хотим">Чего мы хотим</h3>

<p>Мне не нужна Jira и не нужна командная работа в привычном смысле: команда здесь — это я и несколько ИИ. Нужен личный трекер, где ИИ ведут проекты, а решения остаются за мной. Отсюда цели:</p>

<ol>
  <li><strong>Одна задача — один файл.</strong> Формат TaskNotes: Markdown с YAML-шапкой, название задачи = имя файла. Никакой базы данных, никакого облака. Файлы читаются глазами, grep’ом и Obsidian’ом и переживут любое приложение.</li>
  <li><strong>Телефон как главный пульт.</strong> Android-приложение TaskForge: посмотреть, поменять статус, ответить на вопрос, принять решение. Компьютер для этого не нужен.</li>
  <li><strong>ИИ работают через API, а не руками.</strong> Каждый ассистент пишет в трекер только через MCP-сервер или CLI. Самодеятельности в YAML нет: формат знает инструмент, а не память модели. Тут нужно отметить, что я взял TaskForge CLI+MCP но у obsidian и TaskNotes есть свои, разницы для меня по сути нет.</li>
  <li><strong>Трекер — единственный источник правды.</strong> Результаты замеров, решения, вопросы, логи и документы лежат в трекере или привязаны к задаче. Потому нередко можно просто отправить агента в трекер и он найдет немало новых задач, о которых забыл.</li>
  <li><strong>Задача может разбудить ассистента.</strong> Ставлю задаче тег и время — в это время ИИ просыпается и выполняет то, что написано в её теле. Будильник, отложенная проверка или регулярная рутина задаются без отдельного планировщика.</li>
  <li><strong>Защита от дурака, в том числе от умного дурака.</strong> Правила, которые нельзя забыть: валидация на каждой записи, ночные проверки, удаление только через карантин. ИИ иногда очень уверенно делает глупости, поэтому система должна это ловить, а не надеяться на внимательность.</li>
  <li><strong>Выживание после ребута.</strong> Бокс перезагрузился (это у Grok Bot) — синхронизация, циклы и сторожа поднялись сами, а я узнал об этом одной строкой.</li>
</ol>

<h3 id="архитектура">Архитектура</h3>

<p>Вся установка выросла из одной мысли:</p>

<blockquote>
  <p>Трекер — это папка с Markdown-файлами. Всё остальное — клиенты, которые по-разному в эту папку пишут, плюс Syncthing, который пытается договориться о том, какая версия файла правильная.</p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>        ТЕЛЕФОН (Android)                     НОУТБУК
   TaskForge-app + Obsidian           Claude Code: сессии mon-g0, tq-g1, ...
   один vault, 5 папок проектов       taskforge CLI/MCP (обёртка bwrap)
            │                          interconnect (почта ассистентов, tmux)
            │                                   │
            └────────── Syncthing ──────────────┤
                (send-receive, .stversions)      │
                            │                    │
   ═════════════════ Linux-бокс (всегда онлайн) ══════════════════
   /workspace/taskforge-vaults/{admin,turboquant,monitor,liberec,proxy}
      └─ Projects/&lt;Name&gt;/  (реальная папка Syncthing: задачи, Archive/, attachments/)
            ▲
            │ stdio
   taskforge-mcp-proxy (--validate, 8 правил)  ──►  taskforge mcp --stdio
            ▲
   Admin (Grok Bot)  ◄──  tt-loop (раз в минуту: теги agent, напоминания, precheck)
            ▲             selftest 23:50 · validate_vault 03:17 · сторож Syncthing
            └── рутина раз в 10 минут: письма Claude + сработавшие задачи
</code></pre></div></div>

<p>На схеме важны две вещи. Во-первых, у файлов нет главного: телефон, ноутбук и бокс равноправны, и Syncthing по очереди тащит изменения от каждого. Это удобно, пока все правят разное, и превращается в отдельный жанр, когда двое правят одно и то же (см. «Грабли»). Во-вторых, ИИ пишут в трекер только через инструменты. Admin на боксе работает через MCP, обёрнутый в валидирующий прокси. Claude на ноутбуке работает через свой CLI/MCP. Ни один ИИ не сочиняет YAML руками.</p>

<table>
  <thead>
    <tr>
      <th>Компонент</th>
      <th>Где живёт</th>
      <th>Роль</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TaskForge (Android)</td>
      <td>телефон</td>
      <td>Главный пульт: смотреть, менять статусы, ставить напоминания, принимать решения</td>
    </tr>
    <tr>
      <td>Obsidian</td>
      <td>телефон, ноутбук</td>
      <td>Читать и связывать задачи как заметки, смотреть граф ссылок</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">taskforge</code> CLI / <code class="language-plaintext highlighter-rouge">taskforge mcp --stdio</code></td>
      <td>бокс, ноутбук</td>
      <td>Программный доступ к задачам: запросы, создание, правка полей, закрытие</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">taskforge-mcp-proxy</code></td>
      <td>бокс</td>
      <td>Прослойка между ИИ и MCP: подставляет пространство и проверяет каждую запись по 8 правилам</td>
    </tr>
    <tr>
      <td>Syncthing</td>
      <td>телефон, ноутбук, бокс</td>
      <td>Синхронизация 5 папок проектов и общих настроек TaskForge</td>
    </tr>
    <tr>
      <td>tt-loop + selftest</td>
      <td>бокс</td>
      <td>Будит ассистента по задачам с тегом <code class="language-plaintext highlighter-rouge">agent</code>, ночью проверяет сам себя</td>
    </tr>
    <tr>
      <td>Admin (Grok Bot)</td>
      <td>бокс</td>
      <td>Ведёт трекер, управляет сессиями Claude, следит за синхронизацией</td>
    </tr>
    <tr>
      <td>Сессии Claude</td>
      <td>ноутбук (tmux)</td>
      <td>Делают проектную работу по группам задач и пишут результаты в трекер</td>
    </tr>
    <tr>
      <td>interconnect</td>
      <td>ноутбук</td>
      <td>Файловая почта между ассистентами и управление tmux-сессиями Claude</td>
    </tr>
  </tbody>
</table>

<p>Проектов пять, и у каждого свой vault, свой MCP-сервер и своя папка Syncthing:</p>

<p>Свой MCP потому, что мне нужно было внутри одного Obsidian vault на телефоне расшарить на другие устройства подпапки через syncthing и каждому проекту назначить свою папку на боксе (grok bot и ноутбук с Claude), а taskforge MCP такое не может сам по себе, так что пришлось обойти.</p>

<ul>
  <li><strong>admin</strong> — задачи самого Admin по управлению проектами: запуск сессий, сверки, рутины, правила;</li>
  <li><strong>turboquant</strong> — эксперименты с квантованием KV-кэша для локальных LLM (замеры, порт, сборки);</li>
  <li><strong>monitor</strong> — проект сервиса мониторинга;</li>
  <li><strong>liberec</strong> — бытовой личный проект, его ведёт отдельный ассистент;</li>
  <li><strong>proxy</strong> — подпроект прокси памяти для Claude Code.</li>
</ul>

<p>По данным ночной проверки сейчас в них чуть больше двух тысяч задач вместе с архивом. Тысяча с лишним приходится на один Monitor, и это хорошо показывает, сколько мелких шагов набирается, когда каждый шаг ИИ становится задачей. При этом ИИ забывает обычно 95% на долгом проекте.</p>

<h4 id="шаг-0-формат-одна-задача--один-файл">Шаг 0. Формат: одна задача — один файл</h4>

<p>TaskNotes — это простой формат. Задача = файл <code class="language-plaintext highlighter-rouge">.md</code>, метаданные в YAML-шапке, тело — свободный Markdown. Название задачи хранится в имени файла, ключа <code class="language-plaintext highlighter-rouge">title</code> нет. Вот как выглядит типичная задача:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">open</span>
<span class="na">priority</span><span class="pi">:</span> <span class="s">normal</span>
<span class="na">due</span><span class="pi">:</span> <span class="s">2026-10-10</span>
<span class="na">scheduled</span><span class="pi">:</span> <span class="s">2026-10-09</span>
<span class="na">dateCreated</span><span class="pi">:</span> <span class="s">2026-10-08T20:44:57.215+02:00</span>
<span class="na">tags</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">task</span>
<span class="na">contexts</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">pc</span>
<span class="na">projects</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">[[TurboQuant]]"</span>
<span class="na">timeEstimate</span><span class="pi">:</span> <span class="m">15</span>
<span class="na">onCompletion</span><span class="pi">:</span> <span class="s">archive</span>
<span class="na">taskSourceType</span><span class="pi">:</span> <span class="s">taskNotes</span>
<span class="na">dateModified</span><span class="pi">:</span> <span class="s">2026-10-08T20:44:57.215+02:00</span>
<span class="na">id</span><span class="pi">:</span> <span class="s">muzvyzun</span>
<span class="nn">---</span>

Короткое описание, что сделать, и критерий готовности.

Материалы: [[attachments/Название отчёта]]
</code></pre></div></div>

<p>Несколько деталей, о которых стоит знать заранее:</p>

<ul>
  <li>Файл считается задачей, только если в <code class="language-plaintext highlighter-rouge">tags</code> есть <code class="language-plaintext highlighter-rouge">task</code>. Без этого тега файл — просто заметка.</li>
  <li><code class="language-plaintext highlighter-rouge">id</code> — это base36 от времени создания. Он не меняется никогда, даже при переименовании, и машинные ссылки (<code class="language-plaintext highlighter-rouge">depends_on</code>) идут по нему.</li>
  <li><code class="language-plaintext highlighter-rouge">projects</code> со ссылкой на другую задачу означает «подзадача». <code class="language-plaintext highlighter-rouge">blockedBy</code> — это список зависимостей «нельзя начать, пока не закончено то».</li>
  <li>Закрытая задача с <code class="language-plaintext highlighter-rouge">onCompletion: archive</code> переезжает в <code class="language-plaintext highlighter-rouge">Archive/</code>. Переносит её TaskForge на телефоне или инструмент, но никогда не ИИ руками.</li>
  <li>Из имени файла вырезаются <code class="language-plaintext highlighter-rouge">/ \ : * ? " &lt; &gt; | # [ ] ^</code>, а кириллица и пробелы остаются. Заголовок «Решение: да/нет?» превратится в имя файла, по которому потом не найдёшь ни одну вики-ссылку.</li>
</ul>

<p>Структура vault’а одинаковая у всех пяти проектов:</p>

<p>Тут нужно понимать, что это структура на компе Grok Bot в облаке, на моем телефоне где работает TaskForge это один vault, а это сделано как раз потому, что taskforge cli не поддерживает указание отдельных пространств (термин TaskForge) с задачами по разным папкам. Мне нужно было что бы агенты копаясь в своих задачах чужие не трогали.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/workspace/taskforge-vaults/&lt;проект&gt;/
├── Projects/&lt;Name&gt;/          ← реальная папка Syncthing
│   ├── *.md                  ← активные задачи
│   ├── Archive/              ← закрытые
│   └── attachments/          ← документы: docs/, history/, research/, runs/&lt;run-id&gt;/
├── TaskNotes/Archive  →  symlink в Projects/&lt;Name&gt;/Archive
└── TaskForge/Settings →  symlink на общие настройки (dialect.json, spaces.json)
</code></pre></div></div>

<p>Папка проекта должна быть настоящей, а не симлинком: TaskForge CLI не индексирует задачи, до которых добирается только через симлинк. На ноутбуке у Claude по той же причине стоит маленькая обёртка <code class="language-plaintext highlighter-rouge">taskforge-project</code>. Она через <code class="language-plaintext highlighter-rouge">bwrap</code> монтирует реальную папку Syncthing внутрь vault-обёртки в приватном пространстве имён и подкладывает свою копию <code class="language-plaintext highlighter-rouge">dialect.json</code>, где папка новых задач указывает в проект, а не в несинхронизируемый <code class="language-plaintext highlighter-rouge">TaskNotes/Tasks</code>:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># taskforge-project &lt;turboquant|monitor|proxy&gt; &lt;аргументы taskforge&gt;</span>
<span class="nb">exec </span>bwrap <span class="nt">--bind</span> / / <span class="nt">--dev-bind</span> /dev /dev <span class="se">\</span>
  <span class="nt">--bind</span> <span class="s2">"</span><span class="nv">$R</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$V</span><span class="s2">/Projects/</span><span class="nv">$N</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--ro-bind</span> <span class="s2">"</span><span class="nv">$O</span><span class="s2">"</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">readlink</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$DL</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--die-with-parent</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.local/bin/taskforge"</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span>
</code></pre></div></div>

<p>Без sudo, ничего не перемещается, общий файл настроек телефона не меняется. Но это просто “как агент придумал”, я не критиковал его, работает и ладно.</p>

<p>Ещё одно правило, которое я ввёл не сразу: <strong>всё содержимое трекера пишется по-русски</strong>. Это касается названий, тел, логов и решений, кто бы их ни писал. Между собой ассистенты переписываются по-английски, но всё, что читаю я, должно быть по-русски. В трекере я читаю всё, поэтому он русский. Но то, что агенты обязаны говорить между собой ао английски экономит токены.</p>

<h4 id="шаг-1-mcp-ии-пишет-только-через-инструмент">Шаг 1. MCP: ИИ пишет только через инструмент</h4>

<p>Главное правило установки звучит так: <strong>формат задач знает инструмент, а не ИИ.</strong> Модель уверенно напишет YAML «по аналогии», перепутает <code class="language-plaintext highlighter-rouge">in_progress</code> с <code class="language-plaintext highlighter-rouge">in-progress</code>. Каждая такая мелочь ломает приложение на телефоне. Поэтому любые правки задач идут только через MCP-сервер проекта, а если его нет — через CLI. Руками файл не редактирует никто.</p>

<p>На боксе работают пять MCP-серверов, по одному на vault: <code class="language-plaintext highlighter-rouge">taskforge-admin</code>, <code class="language-plaintext highlighter-rouge">taskforge-turboquant</code>, <code class="language-plaintext highlighter-rouge">taskforge-monitor</code>, <code class="language-plaintext highlighter-rouge">taskforge-liberec</code>, <code class="language-plaintext highlighter-rouge">taskforge-proxy</code>. Все они запускаются одинаково, через прокси:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>command: python3
args:    /workspace/agent-tools/taskforge-mcp-proxy/proxy.py \
           --vault /workspace/taskforge-vaults/&lt;имя&gt; --default-space &lt;имя&gt; --validate
env:     TASKFORGE_VAULT=/workspace/taskforge-vaults/&lt;имя&gt;
</code></pre></div></div>

<p>Важная мелочь: <code class="language-plaintext highlighter-rouge">taskforge mcp</code> игнорирует <code class="language-plaintext highlighter-rouge">--vault</code> и берёт путь только из переменной окружения <code class="language-plaintext highlighter-rouge">TASKFORGE_VAULT</code>. Прокси выставляет её сам и без хранилища не стартует.</p>

<p>Как ИИ работает с задачей:</p>

<ol>
  <li>Перед первой записью в сессии вызывает <code class="language-plaintext highlighter-rouge">get_dialect</code>. Это машинное описание настроек vault’а, которое приложение генерирует само: какие статусы есть, как они пишутся в файл, где архив. Например, MCP-токен <code class="language-plaintext highlighter-rouge">todo</code> пишется в файл как <code class="language-plaintext highlighter-rouge">open</code>, а <code class="language-plaintext highlighter-rouge">medium</code> — как <code class="language-plaintext highlighter-rouge">normal</code>.</li>
  <li>Находит задачи через <code class="language-plaintext highlighter-rouge">query_tasks</code> (<code class="language-plaintext highlighter-rouge">status:in_progress path:^Projects/Turboquant/ text:"..."</code>).</li>
  <li>Меняет задачу через <code class="language-plaintext highlighter-rouge">update_task</code>, и меняет точечно: статус отдельно (<code class="language-plaintext highlighter-rouge">set_status</code>), зависимость отдельно (<code class="language-plaintext highlighter-rouge">add_depends_on</code>), лог отдельно (<code class="language-plaintext highlighter-rouge">append_note</code>). MCP не переписывает файл целиком, а меняет конкретное поле, сохраняя всё остальное, включая ключи, которых он не знает.</li>
  <li>Передаёт в каждую правку <code class="language-plaintext highlighter-rouge">if_hash</code> из последнего <code class="language-plaintext highlighter-rouge">get_task</code>. Если хэш не совпал, значит, файл успел поменяться, обычно потому, что я в этот момент правил задачу на телефоне. Тогда ИИ перечитывает задачу, пробует ещё раз и, если снова не вышло, останавливается и сообщает мне.</li>
</ol>

<p>Последний пункт оказался важнее, чем выглядит. Без <code class="language-plaintext highlighter-rouge">if_hash</code> ИИ спокойно затирает правку, которую я сделал на телефоне минуту назад.</p>

<h4 id="шаг-2-валидирующий-прокси-восемь-правил-которые-нельзя-забыть">Шаг 2. Валидирующий прокси: восемь правил, которые нельзя забыть</h4>

<p>Правила в инструкциях ИИ работают примерно как правила в офисной вики: все читали, никто не соблюдает. Поэтому через несколько дней правила переехали из текста в код. Между ассистентом и MCP-сервером стоит <code class="language-plaintext highlighter-rouge">taskforge-mcp-proxy</code> — stdio-прокси на Python, который перехватывает каждый вызов записи и до сервера проверяет его по правилам из <code class="language-plaintext highlighter-rouge">rules.json</code>.</p>

<p>Режимов два. <code class="language-plaintext highlighter-rouge">error</code> — вызов не уходит, ИИ получает «Нарушено правило N (имя): … Исправь: …», исправляет и повторяет. <code class="language-plaintext highlighter-rouge">fix</code> — прокси исправляет сам и пишет в ответ «прокси исправил: …». Режима warn нет сознательно: предупреждения ИИ читают так же, как я читаю лицензионные соглашения.</p>

<ol>
  <li><strong>Связи обязательны.</strong> Задача без связей запрещена: нужна родительская задача, вики-ссылка в теле или зависимость. Исключение — тег <code class="language-plaintext highlighter-rouge">root</code> и пометка «корневая задача проекта». Задача, ни с чем не связанная, — это потерянная задача.</li>
  <li><strong>Без опасных символов в заголовке.</strong> Никаких <code class="language-plaintext highlighter-rouge">/ \ : * ? " &lt; &gt; | # ^ [ ]</code>, иначе ломаются имена файлов и вики-ссылки.</li>
  <li><strong>Задача с тегом <code class="language-plaintext highlighter-rouge">agent</code></strong> должна иметь время в <code class="language-plaintext highlighter-rouge">scheduled</code> и тело не короче 80 символов. Тело — это инструкция, которую ассистент выполнит, когда проснётся, и «сделать» за инструкцию не считается.</li>
  <li><strong>У задачи со временем есть напоминание</strong> (fix). <code class="language-plaintext highlighter-rouge">add_task</code> не умеет писать напоминания, поэтому прокси дописывает их отдельным вызовом после записи.</li>
  <li><strong>При закрытии <code class="language-plaintext highlighter-rouge">onCompletion: archive</code></strong> (fix). В TurboQuant задача-замер не закроется без заполненных <code class="language-plaintext highlighter-rouge">result</code>, <code class="language-plaintext highlighter-rouge">measured_at</code> и <code class="language-plaintext highlighter-rouge">run_dir</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">blocked</code> и <code class="language-plaintext highlighter-rouge">deferred</code> требуют причины и связи.</strong> Нужна строка <code class="language-plaintext highlighter-rouge">Причина: …</code> или <code class="language-plaintext highlighter-rouge">Ждёт: …</code> и ссылка на то, чего задача ждёт. Если она ждёт меня, то ссылка ведёт на задачу «Решение Романа — …».</li>
  <li><strong>Удаление под замком.</strong> Удалять можно только тестовые задачи или задачи с тегом <code class="language-plaintext highlighter-rouge">delete-ok</code>, пачками не больше 25.</li>
  <li><strong>Только своё пространство.</strong> Создавать задачи в чужом проекте или в несинхронизируемой папке нельзя.</li>
</ol>

<p>Кусочек <code class="language-plaintext highlighter-rouge">rules.json</code>, чтобы было понятно, насколько это просто устроено:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"blocked_needs_reason"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"error"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"statuses"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"blocked"</span><span class="p">,</span><span class="w"> </span><span class="s2">"deferred"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"reason_regex"</span><span class="p">:</span><span class="w"> </span><span class="s2">"(?im)^[</span><span class="se">\\</span><span class="s2">s&gt;*_-]*(Причина|Ждёт|Ждет)[*_]*</span><span class="se">\\</span><span class="s2">s*:[*_</span><span class="se">\\</span><span class="s2">s]*</span><span class="se">\\</span><span class="s2">S"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"require_blocker"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"cross_space_prop"</span><span class="p">:</span><span class="w"> </span><span class="s2">"blocked_by_ref"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Файл перечитывается по mtime, перезапуск не нужен. Если файл битый, остаётся прежний рабочий конфиг, а ошибка уходит в журнал. Если сломалась сама проверка, вызов уходит как есть, чтобы прокси никогда не стал причиной, по которой трекер не работает. Все решения прокси пишутся в <code class="language-plaintext highlighter-rouge">validation.log</code>: дата, vault, инструмент, правило, block или fix. К прокси есть тесты на свежей копии Admin-vault’а, боевое хранилище они не трогают.</p>

<p>Прокси ловит только записи через MCP. Телефон, Obsidian и Claude на ноутбуке (у него свой MCP без прокси) пишут мимо него. Поэтому раз в сутки ночью <code class="language-plaintext highlighter-rouge">validate_vault.py</code> проходит по файлам всех хранилищ с теми же правилами и присылает Grok Bot отчёт. В первый же прогон он нашёл больше сотни нарушений в старых задачах; их разобрали за день. Сейчас в типичном отчёте несколько строк.</p>

<h4 id="шаг-3-syncthing-телефон-ноутбук-бокс">Шаг 3. Syncthing: телефон, ноутбук, бокс</h4>

<p>Синхронизацию делает Syncthing. Это шесть папок: пять проектов и общие настройки TaskForge (<code class="language-plaintext highlighter-rouge">dialect.json</code>, <code class="language-plaintext highlighter-rouge">spaces.json</code>), без которых MCP не понимает, где у проекта папки. Режимы такие:</p>

<ul>
  <li>бокс и телефон — <code class="language-plaintext highlighter-rouge">send-receive</code> для всех папок;</li>
  <li>ноутбук — <code class="language-plaintext highlighter-rouge">send-receive</code> для проектов, где работает Claude, и <code class="language-plaintext highlighter-rouge">receive-only</code> для admin: в задачи Admin сессии Claude не пишут;</li>
  <li>версии старых файлов складываются в <code class="language-plaintext highlighter-rouge">.stversions/</code>.</li>
</ul>

<p>Бокс — это общая машина, на которой живут ассистенты Grok Bot. Он иногда перезагружается и обновляется, и при этом теряет часть домашней папки. Поэтому всё, что должно пережить ребут, лежит в <code class="language-plaintext highlighter-rouge">/workspace</code>, а поднимает это идемпотентный скрипт:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># box-ensure.sh — восстанавливает то, что бокс теряет при сбросе, и перезапускает фоновые службы.</span>
<span class="c"># 1) taskforge CLI: восстановить из копии, если npm-установка пропала</span>
<span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-x</span> ~/.local/lib/node_modules/taskforge-md/bin/taskforge.js <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">cp</span> <span class="nt">-a</span> /workspace/agent-tools/taskforge-md-copy/. ~/.local/lib/node_modules/taskforge-md/
  <span class="nb">echo</span> <span class="s2">"restored taskforge-md"</span>
<span class="k">fi</span>
<span class="c"># 2) Syncthing: состояние в /workspace/syncthing-state (--home), ключи зеркалируются в бэкап</span>
/workspace/agent-tools/syncthing/syncthing-ensure
<span class="c"># 3) цикл триггеров задач</span>
bash /workspace/agent-tools/task-trigger/tt-start.sh
</code></pre></div></div>

<p>Ключи и конфиг Syncthing (<code class="language-plaintext highlighter-rouge">key.pem</code>, <code class="language-plaintext highlighter-rouge">cert.pem</code>, <code class="language-plaintext highlighter-rouge">config.xml</code>) <code class="language-plaintext highlighter-rouge">syncthing-ensure</code> зеркалирует в бэкап и возвращает обратно, если каталог состояния пропал. Это правило написано кровью ИИ агентов :)</p>

<p>Сверху работает рутина «Сторож Syncthing и цикла задач». Сначала она запускалась каждый час, сейчас реже, раз в четыре часа: токены не бесплатные. Сторож запускает <code class="language-plaintext highlighter-rouge">box-ensure.sh</code> (если в выводе есть <code class="language-plaintext highlighter-rouge">restored</code> или <code class="language-plaintext highlighter-rouge">started</code>, значит, бокс перезагружался, и я получаю одну строку). Через REST проверяет, что папок ровно столько, сколько должно быть, что все они idle с <code class="language-plaintext highlighter-rouge">need=0</code> и что телефон и ноутбук подключены. Ищет новые <code class="language-plaintext highlighter-rouge">*.sync-conflict-*</code> и проверяет, что цикл триггеров жив. Если всё в порядке, сторож молчит. Пишет мне только при настоящей проблеме.</p>

<h4 id="шаг-4-задачи-которые-будят-ассистента">Шаг 4. Задачи, которые будят ассистента</h4>

<p>Задача с тегом <code class="language-plaintext highlighter-rouge">agent</code> и временем в <code class="language-plaintext highlighter-rouge">scheduled</code> — это будильник для ИИ. В её теле лежит инструкция. Когда наступает время, ассистент просыпается и выполняет её.</p>

<p>Работает это так. На боксе крутится один цикл <code class="language-plaintext highlighter-rouge">tt-loop.py</code>, который раз в минуту через <code class="language-plaintext highlighter-rouge">taskforge</code> CLI опрашивает все хранилища из <code class="language-plaintext highlighter-rouge">vaults.txt</code>. Событий три:</p>

<table>
  <thead>
    <tr>
      <th>kind</th>
      <th>Когда</th>
      <th>Что происходит с задачей</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scheduled</code></td>
      <td>тег <code class="language-plaintext highlighter-rouge">agent</code>, <code class="language-plaintext highlighter-rouge">scheduled</code> ≤ сейчас, задача не в работе и не закрыта</td>
      <td>разовая переходит в <code class="language-plaintext highlighter-rouge">in_progress</code>, повторяющаяся сдвигается на следующее вхождение (пропущенные склеиваются в одно пробуждение)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">reminder</code></td>
      <td>наступило напоминание TaskNotes (<code class="language-plaintext highlighter-rouge">PT0M</code>, <code class="language-plaintext highlighter-rouge">-PT1H</code>, абсолютное время)</td>
      <td>ничего: статус не меняется</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">precheck</code></td>
      <td>за 90 минут до дедлайна ЛЮБОЙ открытой задачи со временем</td>
      <td>ничего: ассистент только проверяет, стоит ли на задаче будильник или напоминание, и чинит, если нет</td>
    </tr>
  </tbody>
</table>

<p>Последний тип события — страховка от меня. Если я поставил задачу «к 15:00», но забыл тег или напоминание, ассистент заметит это за полтора часа, а не после того, как дедлайн прошёл.</p>

<p>Изначально цикл слал вебхуки, и ассистент просыпался мгновенно. Потом от вебхуков отказались (кто то ддосит Grok Bot), и теперь доставка выглядит так:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"delivery"</span><span class="p">:</span><span class="w"> </span><span class="s2">"log"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="s2">"admin"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"rules"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"vault"</span><span class="p">:</span><span class="w"> </span><span class="s2">"liberec"</span><span class="p">,</span><span class="w"> </span><span class="nl">"recipient"</span><span class="p">:</span><span class="w"> </span><span class="s2">"admin"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"agent-lberec"</span><span class="p">,</span><span class="w"> </span><span class="nl">"recipient"</span><span class="p">:</span><span class="w"> </span><span class="s2">"admin"</span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"vault_options"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"liberec"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"reminders_any_tag"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Цикл пишет строки <code class="language-plaintext highlighter-rouge">[EVENT]</code> в свой журнал, а Admin раз в 10 минут запускает одну рутину «Проверка писем Claude и задач TaskForge». Сначала идёт дешёвая предпроверка: маленький скрипт на боксе смотрит события, а такой же скрипт на ноутбуке смотрит почту. Если ответ <code class="language-plaintext highlighter-rouge">NONE</code>, рутина сразу заканчивается и почти не тратит токенов. Если что-то есть, Admin обрабатывает своё, а чужое пересылает адресату: например, события бытового проекта уходят его ассистенту. Остальные боты собственных опросов не держат. Опрашивает один, и платим за один.</p>

<p>Выполнив разовую задачу, ассистент закрывает её. Если работа длинная, он оставляет задачу в <code class="language-plaintext highlighter-rouge">in_progress</code>, пишет в тело, что запущено и где лог, и ставит себе новую задачу-будильник «проверить в 06:45». Так у меня в трекере появились задачи вида «Admin — будильник 14.10 06:45, проверка приёмки шага 2».</p>

<p><strong>Ночной самотест.</strong> Каждый день в 23:50 <code class="language-plaintext highlighter-rouge">selftest.py</code> создаёт в Admin-vault’е задачу <code class="language-plaintext highlighter-rouge">admin-selftest-&lt;дата&gt;</code> с тегом <code class="language-plaintext highlighter-rouge">agent</code> и временем «через две минуты». Он ждёт, пока цикл её заметит, отправит событие и переведёт задачу в работу, а потом закрывает её. Любой сбой дописывается в <code class="language-plaintext highlighter-rouge">selftest-fail.txt</code>. Нет файла — нет проблем; есть файл — Grok Bot узнает. Самотест честно доказывает только то, что цикл жив и события уходят. То, что ассистент проснулся и что-то сделал, проверяют уже по результатам в трекере.</p>

<h4 id="шаг-5-сессии-claude-сначала-задача-потом-код">Шаг 5. Сессии Claude: сначала задача, потом код</h4>

<p>Проектную работу делает Claude Code на ноутбуке. Одна длинная сессия на весь проект быстро переполняет контекст, начинает забывать начало и дорого стоит. Поэтому работа нарезана на <strong>группы задач</strong>, и каждая группа получает свою свежую сессию: <code class="language-plaintext highlighter-rouge">mon-g0</code>, <code class="language-plaintext highlighter-rouge">tq-g1</code> и так далее. Открывает и закрывает их Admin через <code class="language-plaintext highlighter-rouge">interconnect</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># открыть сессию под группу, связав её с задачей-хабом группы</span>
python3 interconnect.py open tq-g5 ~/projects/turboquant <span class="nt">--task</span> turboquant:&lt;id-хаба&gt;

<span class="c"># кто чем занят: состояние, время в нём, непрочитанные письма, связанная задача</span>
python3 interconnect.py sessions
python3 interconnect.py watch <span class="nt">--idle-min</span> 60     <span class="c"># сторож: печатает только проблемы</span>

<span class="c"># письмо в ящик сессии, затем «толчок» в терминал, если она ждёт ввода</span>
python3 interconnect.py send <span class="nt">--from</span> admin <span class="nt">--to</span> tq-g5 <span class="s2">"Start: hub task &lt;id&gt;, rules in the start letter"</span>
python3 interconnect.py nudge tq-g5
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">interconnect</code> — это файловые почтовые ящики с <code class="language-plaintext highlighter-rouge">flock</code> плюс управление tmux-сессиями. Скрипт сам ничего не решает и ничего не нажимает. Он сообщает состояние (РАБОТАЕТ, ЖДЁТ ВВОДА, ЖДЁТ ПОДТВЕРЖДЕНИЯ, ЛИМИТ, УПАЛА), а решает Grok Bot Admin. Каждые 15 минут сторож запускает <code class="language-plaintext highlighter-rouge">watch</code>. Пустой вывод значит, что всё хорошо. «ЖДЁТ ВВОДА + непрочитанные» значит <code class="language-plaintext highlighter-rouge">nudge</code>. Меню и подтверждения разбираются по правилам, а всё, что касается удаления или публикации, решаю я.</p>

<p>Правила для сессий родились из конкретных неприятностей, и каждое теперь вписывается в стартовое письмо каждой сессии:</p>

<ul>
  <li><strong>Сначала задача, потом реализация.</strong> Перед тем как делать шаг, сессия заводит на него задачу в трекере через MCP. Потом делает шаг и записывает результат в эту задачу. Так в трекере остаётся полный путь разработки, а не только итог. Отсюда и тысяча задач в одном проекте.</li>
  <li><strong>Вопросы только письмом.</strong> Сессия, которая молча ждёт ответа в терминале, считается ошибкой. Любой вопрос, решение или блокировку, включая запросы подтверждения от самого Claude Code, сессия отправляет письмом в ящик <code class="language-plaintext highlighter-rouge">admin</code> и продолжает другую работу.</li>
  <li><strong>Результаты перепроверяет тот, кто их получил.</strong> Если замер выглядит подозрительно, Admin сам в логи не лезет, а поручает проверку сессии и обсуждает с ней вывод до согласия. Ко мне приходят только вопросы, которые остались неясными, по одному, с вариантами и рекомендацией.</li>
  <li><strong>Знания попадают в трекер до закрытия.</strong> Перед закрытием сессия сдаёт handoff — документ во вложениях проекта: что сделано, что прервано, как продолжить, что предлагается дальше. Каждая новая идея, находка, проблема и новая работа в handoff должна иметь id задачи в трекере. Admin сверяет handoff с трекером построчно, по таблице «пункт → id». Пункт без id возвращается сессии.</li>
  <li><strong>Закрыть без сверки нельзя технически.</strong> <code class="language-plaintext highlighter-rouge">close</code> без флага <code class="language-plaintext highlighter-rouge">--reconciled</code> откажет, а пока сессия работает, тоже откажет:</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ python3 interconnect.py close cs-test
close: сверка не отмечена (задача: admin:...). Сначала handoff + задачи/хабы через MCP
       + сверка Admin + «сверка ок», затем повторить с --reconciled
</code></pre></div></div>

<p>Порядок всегда одинаковый: handoff → сверка трекера (задачи, хабы, дубли) → письмо «сверка ок» → <code class="language-plaintext highlighter-rouge">close --reconciled</code> → снимок экрана в <code class="language-plaintext highlighter-rouge">logs/closed/</code>, путь к нему записывается в задачу. Логи сессий не удаляются никогда.</p>

<p>Мне нравится, как это смотрится с телефона. Я открываю TaskForge и вижу хаб группы, его подзадачи с результатами и handoff со ссылками. Если хочется, через remote control Claude подглядываю в саму сессию. Решения, которые ждут меня, лежат отдельными задачами «Решение пользователя — …», и от них по <code class="language-plaintext highlighter-rouge">depends_on</code> зависят задачи, которые без меня не двинутся.</p>

<h4 id="шаг-6-правила-без-которых-всё-это-не-живёт">Шаг 6. Правила, без которых всё это не живёт</h4>

<p>Часть правил стоит в прокси, а часть — это договорённости, которые записаны в навыках ассистентов и проверяются ночью:</p>

<ul>
  <li><strong>Любое удаление — через карантин.</strong> Ничего не удаляется напрямую. Сначала объекты переносятся в <code class="language-plaintext highlighter-rouge">quarantine/&lt;дата&gt;-&lt;задача&gt;/</code> с манифестом (путь, размер, sha256). Git-ветки сначала упаковываются в <code class="language-plaintext highlighter-rouge">git bundle</code>, бандл проверяется <code class="language-plaintext highlighter-rouge">git bundle verify</code>, его sha256 записывается в задачу. Затем проверяется, что ничего нужного не ушло. Окончательное удаление идёт отдельным шагом, только после моего «ок», и записывается в задачу. Это касается даже чисток, которые я сам одобрил.</li>
  <li><strong>Дубли не удаляются, а отменяются со ссылкой.</strong> Дубль получает строку «Дубль: [[основная задача]]» и статус <code class="language-plaintext highlighter-rouge">cancelled</code>. История остаётся, ссылки на дубль продолжают работать, а при следующей миграции понятно, откуда что взялось.</li>
  <li><strong>Заблокированная задача ссылается на то, чего ждёт.</strong> «Ждёт: ответа» без ссылки не считается. Если задача ждёт меня, то её блокирует задача-решение, и я вижу полный список ожидающих.</li>
  <li><strong>Документы переносятся в трекер, а не копируются.</strong> Отчёт, лог, план или выгрузку кладут в <code class="language-plaintext highlighter-rouge">attachments/</code> проекта и ссылаются на них из задачи. После проверки sha256 и ссылки оригинал удаляется (тоже через карантин). Документ без задачи значит потерянную задачу. Модели, бинарники, секреты и файлы больше ~50 МБ в трекер не попадают, в задаче записывается только путь к ним.</li>
  <li><strong>Задачи читаем раньше документов.</strong> Чтобы экономить токены, ассистент сначала читает задачи (там лежат свойства и выжимки), идёт по ссылкам на другие задачи и только при нехватке данных открывает вложения. Тяжёлое чтение он поручает исполнителю и просит прислать только выводы.</li>
</ul>

<h3 id="грабли">Грабли</h3>

<p>Места, где ИИ потратил время (а пару раз чуть не потерял данные), чтобы вы не потратили.</p>

<ol>
  <li><strong>Syncthing разрешает конфликты молча.</strong> Если две стороны поправили один файл, Syncthing оставит под нормальным именем более новую по mtime версию, а старую положит рядом как <code class="language-plaintext highlighter-rouge">&lt;задача&gt;.sync-conflict-&lt;ДАТА&gt;-&lt;ВРЕМЯ&gt;-&lt;УСТРОЙСТВО&gt;.md</code>. Уведомлений не.</li>
  <li><strong>Бокс потерял ключи Syncthing.</strong> Однажды при обновлении (комп облачный Grok Bot) каталог состояния Syncthing на боксе пересоздался.</li>
  <li><strong>Телефон завис на 95% из-за длины имени файла.</strong> Конфликтная копия задачи с длинным русским названием получила имя в 251 байт.</li>
  <li><strong>Ребут воскресил двести закрытых задач.</strong> После перезагрузки бокса около двухсот уже закрытых задач во всех пяти проектах вернулись в активные папки как старые копии, а их актуальные закрытые версии остались в <code class="language-plaintext highlighter-rouge">Archive/</code>. Syncthing сделит за версиями, боты разобрали все сами.</li>
  <li><strong>ИИ сочиняет формат по памяти.</strong> До MCP-only ассистент однажды «аккуратно поправил» несколько задач руками, по аналогии с соседними. Приложение на телефоне потом показывало их без статуса.</li>
  <li><strong>Новые задачи уезжали в несинхронизируемую папку.</strong> По умолчанию TaskForge кладёт новые задачи в <code class="language-plaintext highlighter-rouge">TaskNotes/Tasks</code>, а эта папка не синхронизируется. Задачи создавались и исчезали для всех, кроме того, кто их создал. На боксе это лечит прокси (подставляет пространство и запрещает <code class="language-plaintext highlighter-rouge">TaskNotes/Tasks</code> правилом 8), на ноутбуке — обёртка с подменой <code class="language-plaintext highlighter-rouge">dialect.json</code>. Перенести задачу оттуда на ноутбуке нельзя: <code class="language-plaintext highlighter-rouge">move_task</code> падает с <code class="language-plaintext highlighter-rouge">EXDEV</code> через bind mount. Поэтому о таких задачах ИИ сообщает, а не чинит их сам.</li>
  <li><strong>Сессия Claude молча ждёт.</strong> Claude Code любит спросить «Do you want to proceed?» и ждать часами. Сессия при этом выглядит живой, а работа стоит. Отсюда правило «вопросы только письмом», сторож <code class="language-plaintext highlighter-rouge">watch</code> и проверка, что <code class="language-plaintext highlighter-rouge">nudge</code> действительно нажал Enter (до трёх нажатий, затем «Enter НЕ принят» и разбор по экрану). После сброса лимита Claude сам работу не продолжает, его нужно толкнуть этим занимается Grok Bot.</li>
  <li><strong>Знания умирают вместе с сессией.</strong> Первые сессии закрывались с хорошим handoff, но половина находок в нём не превратилась в задачи и через день была забыта. Теперь действует гейт: каждый пункт handoff сопоставлен с id в трекере, иначе <code class="language-plaintext highlighter-rouge">close --reconciled</code> не выполняется.</li>
  <li><strong>Вебхуки оказались лишними.</strong> Мгновенное пробуждение по вебхуку выглядело красиво, пока вебхук не начал отвечать HTTP 400 на каждое письмо так как Grok ддосят.</li>
</ol>

<h3 id="что-в-итоге">Что в итоге</h3>

<ul>
  <li><strong>Трекер — это папка с файлами.</strong> Пять проектов, одна задача = один Markdown-файл. Читается глазами, Obsidian’ом, grep’ом и переживёт любое приложение. Ни одного облака по дороге.</li>
  <li><strong>Телефон — главный пульт.</strong> Я вижу всё, что делают ИИ: каждый шаг, каждый результат, каждое решение, которое ждёт меня, с зависимостями и ссылками на документы.</li>
  <li><strong>ИИ работают через инструменты, а не руками.</strong> MCP с валидирующим прокси на боксе, CLI/MCP на ноутбуке, восемь правил на каждой записи и ночная проверка всех файлов.</li>
  <li><strong>Задачи будят ассистента.</strong> Тег <code class="language-plaintext highlighter-rouge">agent</code> и время превращают задачу в будильник или рутину. Precheck страхует от забытых напоминаний, ночной самотест подтверждает, что цикл жив.</li>
  <li><strong>Сессии Claude не уносят знания с собой.</strong> Задача на каждый шаг до реализации, handoff со сверкой по id, закрытие только после «сверка ок».</li>
  <li><strong>Ничего не удаляется сразу.</strong> Карантин с манифестом, дубли отменяются со ссылкой, конфликты Syncthing разбираются вручную, а не «новее значит правильнее».</li>
  <li><strong>Всё поднимается после ребута само</strong>, а я узнаю об этом одной строкой. Ребут 14.10 правда показал, что «поднялось» и «осталось как было» — разные вещи, и разбор воскресших задач ещё впереди.</li>
</ul>

<p>Рутину — скрипты, прокси, валидацию, сторожей, разбор конфликтов, сверку handoff, даже большую часть этих правил в виде текста — делали ИИ. Решения «что удаляем, что считаем дублем, когда закрываем сессию, какое правило становится жёстким» оставались за мной. Каждое такое решение лежит в трекере отдельной задачей с датой. Забавно, что половина правил выше появилась как реакция на то, что ИИ сделал что-то очень уверенно и очень не так. Трекер, в котором работают ИИ, в итоге оказался не про задачи, а про то, как сделать ошибку дешёвой и заметной.</p>

<p>P.S. Самым полезным правилом оказалось не самое умное, а самое занудное: «сначала заведи задачу на шаг, потом делай». Сессии пишут тысячу задач, но теперь на вопрос «а почему мы так решили неделю назад» есть ответ в трекере, а не в закрытой сессии.</p>]]></content><author><name></name></author><category term="ai" /><category term="selfhosted" /><summary type="html"><![CDATA[Свой трекер задач, в который пустил работать ИИ: задачи в Markdown-файлах, синхронизация между телефоном, ноутбуком и Linux-боксом, TaskForge, Obsidian, CLI, MCP и два вида ИИ-ассистентов. Как это устроено, какие правила пришлось ввести и где я наступил на грабли.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/taskforge-cover.png" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/taskforge-cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Личное облако на Proxmox: как собрать себе фотохостинг с мобильным клиентом</title><link href="https://3draven.github.io/blog/2026/07/19/proxmox-photo-hosting.html" rel="alternate" type="text/html" title="Личное облако на Proxmox: как собрать себе фотохостинг с мобильным клиентом" /><published>2026-07-19T10:58:26+00:00</published><updated>2026-07-19T10:58:26+00:00</updated><id>https://3draven.github.io/blog/2026/07/19/proxmox-photo-hosting</id><content type="html" xml:base="https://3draven.github.io/blog/2026/07/19/proxmox-photo-hosting.html"><![CDATA[<p><img src="/blog/assets/images/proxmox-photo-hosting/cover.png" alt="" /></p>

<p>В прошлой <a href="/blog/2026/07/16/proxmox-music-streaming.html">статье</a> я писал как перенести свою музыку себе домой, а это руководство для того, кто решил забрать свой фотоархив домой. От пустого LXC-контейнера до работающего Immich: свой «Google Photos» с умным поиском и лицами на встроенном GPU, доступ с телефона из любой точки мира — и перенос семнадцати тысяч фотографий из Google без потери единого кадра и с восстановлением геолокации.</p>

<h3 id="с-чего-всё-началось">С чего всё началось</h3>

<p>В прошлый раз я собирал себе <a href="/blog/2026/07/16/proxmox-music-streaming.html">музыкальный стек вместо YouTube Music</a> — и обещал «другую историю» про фотографии. Вот она.</p>

<p>Мотивация тут даже проще, чем с музыкой. Фотоархив — весь мой архив жил у Google: подписка за хранилище, «умные» функции, которые то появляются, то переезжают в платный тариф, и полная непрозрачность того, что с моими снимками происходит. Плюс у меня накопился второй архив в MEGA — старый телефонный хлам, свадьба друга, сканы документов — который вообще нигде не был разобран.</p>

<p>Захотелось простого: все фотографии в одном месте, на моём диске, с поиском «фото с котом на снегу» без отправки котов в чужое облако. И чтобы телефон продолжал работать как раньше — открыл приложение, а там всё.</p>

<p>Сразу честно: самохостинг фотографий — это добровольное назначение себя бэкап-инженером. Google хотя бы ничего не терял; теперь если потеряю — сам. Поэтому половина этой статьи не про «как поставить Immich», а про сверки, страховки и порядок действий, при котором потерять нельзя. Причем выкачанные архивы я так и не удалю.</p>

<h3 id="чего-мы-хотим">Чего мы хотим</h3>

<p>Мне не нужен фотохостинг для публики и не нужна «социальность» — нужен личный архив, который переживёт любое облако и чужие продуктовые решения. Максимум это расшарить по ссылке кому то фоточки. Отсюда цели:</p>

<ol>
  <li>
    <p><strong>Весь архив на своём диске.</strong> Ядро — самохостовый Immich: лента, альбомы, карта, умный поиск, лица. Файлы у меня, в RAID, с бэкапами. RAID это просто коробка куда воткнуты два HDD,подключенная к миниПК по usb 3.2 gen2, я в прошлый статьях о ней писал.</p>
  </li>
  <li>
    <p><strong>ML локально, не в облаке.</strong> Поиск по содержимому (не пробовал) и распознавание лиц (пробовал) — на интегрированной видеокарте домашнего сервера. Фотографии не покидают дом ради «умных» функций.</p>
  </li>
  <li>
    <p><strong>Телефон как раньше.</strong> Родное приложение Immich снаружи, из любой сети — но не «порт наружу», а mTLS: без клиентского сертификата сервер для интернета не существует.</p>
  </li>
  <li>
    <p><strong>Перенос без потерь.</strong> Google Photos + MEGA-архив → Immich, со сверками на каждом шаге. Из Google не удаляется ничего, пока целостность не доказана счётчиками. При этом архив выгрузки я естественно вообще удалять не буду никогда.</p>
  </li>
  <li>
    <p><strong>Вернуть геолокацию.</strong> Google прячет координаты при экспорте где может, половина архива вообще снята без GPS. Хочу восстановить географию — честно, с пометкой «откуда взято», не выдавая реконструкцию за правду камеры. Они до 2024 года следили за всеми пользователями постоянно (если кто не знал), но потом говорят, что удалили, но может просто спрятали полную геолокацию, так что тут был пробел и пришлось немного думать.</p>
  </li>
  <li>
    <p><strong>Выжить после ребута.</strong> Сервер перезагрузился — всё поднялось само. Ноутбук админа в этом не участвует.</p>
  </li>
</ol>

<h3 id="архитектура">Архитектура</h3>

<p>Одна мысль, из которой растёт вся установка:</p>

<blockquote>
  <p><strong>Immich — это четыре контейнера и одна папка. Всё, что делает установку «своей», живёт вокруг: проброс iGPU в unprivileged LXC, mTLS на входе и дисциплина наполнения, при которой источник не удаляется, пока копия не доказана.</strong></p>
</blockquote>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  ТЕЛЕФОН (Immich-app)              БРАУЗЕР (ноут)
       │ api.imm.example.com            │ imm.example.com
       │      mTLS :443                 │    mTLS :443
       ▼                                ▼
  edge-VPS (белый IP) ── WireGuard ── Caddy на роутере (OPNsense)
       tls { client_auth CA }   ← без серта отлуп на TLS-хендшейке
       api.:  только /api*, /share*, /.well-known/*  (админки нет)
       imm.:  полный веб-интерфейс + админка
                                        │ plain http, внутренний VLAN
  ══════ Proxmox ── LXC «immich» (unprivileged, Docker) ══════
                                        ▼
   immich_server :2283 ── valkey ── postgres (VectorChord)
        │  /data = ./library  (139 ГБ, btrfs RAID1)
   immich_machine_learning (-openvino) ◄── /dev/dri (Intel Iris Xe)
        CLIP smart-search + лица, всё локально
</code></pre></div></div>

<p>Со схемы важно прочитать две вещи. Первое: <strong>наружу торчит только :443 с обязательным клиентским сертификатом</strong> — для сканеров интернета сервера просто нет, и это снимает главный страх «фотографии всей жизни на самодельном сервере». Второе: <strong>вся установка — одна папка</strong> <strong><code class="language-plaintext highlighter-rouge">~/immich-app</code></strong> (compose, фото, база, кэш моделей), и это осознанное решение: один снапшот-бэкап LXC забирает установку целиком, восстановление — одна операция. Причем весь ML стек это просто встроенная GPU, я как то писал уже <a href="/blog/2025/11/17/proxmox-llm-embedding.html">статью</a> про это и там говорил, что потом расскажу зачем возня, вот за этим, надо было разобраться.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Контейнер</td>
      <td>Образ</td>
      <td>Роль</td>
    </tr>
    <tr>
      <td><strong>immich_server</strong></td>
      <td><code class="language-plaintext highlighter-rouge">ghcr.io/immich-app/immich-server:v3.0.2</code></td>
      <td>Ядро: API, веб, загрузка, фоновые джобы</td>
    </tr>
    <tr>
      <td><strong>immich_machine_learning</strong></td>
      <td><code class="language-plaintext highlighter-rouge">…immich-machine-learning:v3.0.2-openvino</code></td>
      <td>CLIP-поиск по содержимому, лица. OpenVINO на iGPU</td>
    </tr>
    <tr>
      <td><strong>database</strong></td>
      <td><code class="language-plaintext highlighter-rouge">ghcr.io/immich-app/postgres:14-vectorchord0.4.3…</code></td>
      <td>Postgres + векторный индекс (родной образ Immich)</td>
    </tr>
    <tr>
      <td><strong>redis</strong></td>
      <td><code class="language-plaintext highlighter-rouge">valkey/valkey:9</code></td>
      <td>Очереди фоновых задач</td>
    </tr>
  </tbody>
</table>

<p>Железо скромное: мини-ПК на i5-1240P с интегрированной Iris Xe, диски — 2×8 ТБ USB в btrfs RAID1. Никакой дискретной видеокарты для ML не нужно — и это, пожалуй, главная приятная новость всей затеи.</p>

<h3 id="установка">Установка</h3>

<h4 id="шаг-0-lxc--то-что-делается-только-с-хоста">Шаг 0. LXC — то, что делается только с хоста</h4>

<p>Immich живёт в отдельном unprivileged-LXC (Ubuntu 24.04, docker внутри). Единственное специальное — проброс render-ноды iGPU для ML:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># на хосте Proxmox, под root (116 — номер вашего CT)</span>
pct <span class="nb">set </span>116 <span class="nt">--dev1</span> /dev/dri/renderD128,gid<span class="o">=</span>993,uid<span class="o">=</span>1000
pct <span class="nb">set </span>116 <span class="nt">--features</span> <span class="nv">nesting</span><span class="o">=</span>1
pct <span class="nb">set </span>116 <span class="nt">--onboot</span> 1
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">gid=993</code> — группа <code class="language-plaintext highlighter-rouge">render</code> хоста, <code class="language-plaintext highlighter-rouge">uid=1000</code> — обычный пользователь внутри CT: нода приезжает в контейнер уже с правильным владельцем, и дальше всё работает без единого послабления безопасности. Два популярных совета из старых гайдов <strong>не понадобились</strong>, и это стоит проговорить: <code class="language-plaintext highlighter-rouge">apparmor: unconfined</code> не нужен на актуальном Proxmox (баг runc, из-за которого совет появился, в свежих пакетах lxc исправлен), а <code class="language-plaintext highlighter-rouge">keyctl=1</code> Docker в этой конфигурации не требует — проверено запуском: seccomp отдаёт <code class="language-plaintext highlighter-rouge">keyctl()</code> как «не реализовано», и никто не жалуется. Чем меньше дырок в изоляции — тем спокойнее спится.</p>

<p>Внутри CT — обычный docker + compose, и дальше всё под обычным пользователем (uid 1000). Root — только для systemd-юнита автозапуска. Управление — <code class="language-plaintext highlighter-rouge">sudo docker compose</code>, пользователя в docker-группу <strong>не</strong> добавляем (членство в ней — это root-эквивалент, sudo хотя бы просит пароль и пишет лог).</p>

<h4 id="шаг-1-официальные-файлы--четыре-правки">Шаг 1. Официальные файлы + четыре правки</h4>

<p>Immich ставится из официальных файлов релиза — меньше самодеятельности, легче обновляться, но тут по вкусу:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> ~/immich-app <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/immich-app
wget <span class="nt">-O</span> docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget <span class="nt">-O</span> hwaccel.ml.yml     https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml
wget <span class="nt">-O</span> .env               https://github.com/immich-app/immich/releases/latest/download/example.env
</code></pre></div></div>

<p>В <code class="language-plaintext highlighter-rouge">.env</code> правим четыре значения:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>UPLOAD_LOCATION=./library      # фото — рядом с compose, в rootfs CT
DB_DATA_LOCATION=./postgres    # база — там же
TZ=Europe/Amsterdam
IMMICH_VERSION=v3              # мажорный канал, НЕ release (см. грабли); параноикам — пин v3.0.2
DB_PASSWORD=&lt;только A-Za-z0-9, без спецсимволов&gt;
</code></pre></div></div>

<p>Относительные пути: вся установка (ОС, docker, фото, база, кэш моделей) лежит в одном rootfs контейнера, и еженедельный vzdump в режиме снапшота забирает её целиком, crash-consistent — Postgres при восстановлении доиграет WAL сам. Один бэкап = цельная установка.</p>

<p>В <code class="language-plaintext highlighter-rouge">docker-compose.yml</code> — две правки под OpenVINO и одна под HDD:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">immich-machine-learning</span><span class="pi">:</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">immich_machine_learning</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-openvino</span>
    <span class="na">extends</span><span class="pi">:</span>
      <span class="na">file</span><span class="pi">:</span> <span class="s">hwaccel.ml.yml</span>
      <span class="na">service</span><span class="pi">:</span> <span class="s">openvino</span>          <span class="c1"># /dev/dri уезжает в контейнер отсюда</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">group_add</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">993"</span><span class="pi">]</span>           <span class="c1"># render-группа: доступ к renderD128 под non-root</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">HOME</span><span class="pi">:</span> <span class="s">/cache</span>               <span class="c1"># у uid1000 нет HOME → кэши моделей падают без этого</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./model-cache:/cache</span>     <span class="c1"># bind вместо named-тома: владелец — вы, не root</span>
    <span class="na">env_file</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">.env</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">always</span>

  <span class="na">database</span><span class="pi">:</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">DB_STORAGE_TYPE</span><span class="pi">:</span> <span class="s1">'</span><span class="s">HDD'</span>     <span class="c1"># у нас btrfs на USB-дисках, не NVMe</span>
</code></pre></div></div>

<p>Остальным сервисам добавляем только <code class="language-plaintext highlighter-rouge">user: "1000:1000"</code> — весь стек работает под обычным пользователем, включая Postgres (его данные принадлежат uid 1000, он и бежит от uid 1000). Драйверы Intel внутрь CT ставить <strong>не нужно</strong>: образ <code class="language-plaintext highlighter-rouge">-openvino</code> несёт весь рантайм в себе, от хоста требуется только kernel-драйвер i915 и проброшенная нода.</p>

<h4 id="шаг-2-запуск-и-проверка-что-ml-реально-на-видеокарте">Шаг 2. Запуск и проверка, что ML реально на видеокарте</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/immich-app <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>Четыре контейнера должны стать healthy, веб отвечает на <code class="language-plaintext highlighter-rouge">:2283</code> — создаём администратора. Дальше проверка, ради которой всё затевалось:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>docker logs immich_machine_learning 2&gt;&amp;1 | <span class="nb">grep</span> <span class="nt">-i</span> provider
<span class="c">#  → Setting execution providers to ['OpenVINOExecutionProvider', ...]</span>
intel_gpu_top   <span class="c"># на хосте, во время джоба Smart Search: движки iGPU должны шевелиться</span>
</code></pre></div></div>

<p>Если в логе только <code class="language-plaintext highlighter-rouge">CPUExecutionProvider</code> — ML работает, но на процессоре, и первичная индексация двадцати тысяч фото будет есть CPU, а GPU будет простаивать (проверять это надо <strong>до</strong> заливки семнадцати тысяч фото, а не после). Первый прогон разово скачает модели (CLIP + детектор лиц) в <code class="language-plaintext highlighter-rouge">model-cache</code> — около 7 ГБ.</p>

<h4 id="шаг-3-автозапуск">Шаг 3. Автозапуск</h4>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># /etc/systemd/system/immich.service (в CT, под root)
[Unit]
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/&lt;user&gt;/immich-app
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">systemctl enable immich.service</code> — и вместе с <code class="language-plaintext highlighter-rouge">restart: always</code> у контейнеров и <code class="language-plaintext highlighter-rouge">onboot=1</code> у самого LXC получаем три уровня «поднимись сам»: за «сервер перезагрузился, фотографий нет» просыпаться не любит никто. Ребут-тест обязателен: перезагрузить CT и убедиться, что всё встало без рук — <strong>до</strong> того, как сервер станет единственным местом с фотографиями.</p>

<h4 id="шаг-4-доступ-снаружи-mtls-и-два-хоста">Шаг 4. Доступ снаружи: mTLS и два хоста</h4>

<p>Сначала — почему так строго. У Immich <strong>нет ни 2FA, ни защиты от брутфорса</strong>. То есть форма логина, торчащая в интернет голым портом, защищена одним паролем — для архива всей жизни этого мало. Значит, вся безопасность живёт на входе.</p>

<p>Наружу Immich выходит через reverse-proxy (у меня Caddy на роутере; до него — VPS с белым IP, который <strong>не терминирует TLS</strong>, а гонит сырой :443 через WireGuard домой — но это детали конкретной сети, важна сама схема). Ключевое решение — <strong>два разных хоста с разной шириной доступа</strong>, оба за mTLS:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Браузер: полный Immich, включая админку
imm.example.com {
    tls { client_auth { trust_pool file ca.pem } }
    handle { reverse_proxy immich.vlan:2283 }
}

# Телефон: ТОЛЬКО пути, нужные приложению. Корня нет → админки снаружи нет
api.imm.example.com {
    tls { client_auth { trust_pool file ca.pem } }   # тот же серт, что в Android
    handle /api/*               { reverse_proxy immich.vlan:2283 }
    handle /.well-known/immich* { reverse_proxy immich.vlan:2283 }
    handle /share/*             { reverse_proxy immich.vlan:2283 }
    handle /_app/*              { reverse_proxy immich.vlan:2283 }
    handle /custom.css          { reverse_proxy immich.vlan:2283 }
    handle /favicon*            { reverse_proxy immich.vlan:2283 }
}
</code></pre></div></div>

<p>В целом можно и одним обойтись и не заниматься выделением эндпоинтов, я два сделал потому, что поначалу mTLS не выходило в immich добавить так как там UI не очевидный, ниже напишу. <code class="language-plaintext highlighter-rouge">ca.pem</code> — собственный CA (создаётся в пару кликов в любом роутере/PKI; у меня — в OPNsense System→Trust), из него же выписываются клиентские сертификаты: <code class="language-plaintext highlighter-rouge">.p12</code> для ноутбука и телефона. Серверный сертификат при этом обычный Let’s Encrypt, так что в доверенные корни телефона ничего добавлять не нужно — ставится только клиентский. Без сертификата соединение умирает на TLS-хендшейке — до HTTP, до какого-либо «login», до самого Immich дело даже не доходит. Сканеры интернета видят закрытую дверь.</p>

<p>Родное приложение Immich клиентские сертификаты поддерживает (Settings → Advanced → «SSL client certificate» import, помечено experimental — но работает, включая фоновый бэкап). С двумя засадами — они в граблях (№6).</p>

<h3 id="наполнение-google-photos--immich-без-потерь">Наполнение: Google Photos → Immich без потерь</h3>

<p>Теперь главное. Перенос фотоархива — это не «скачал и залил»: из Google <strong>ничего не удаляется</strong>, пока каждая партия не доказана счётчиками. Весь порядок ниже — в этой логике.</p>

<h4 id="takeout-и-его-проверка">Takeout и его проверка</h4>

<p>Экспорт — <a href="https://takeout.google.com/">Google Takeout</a>, только Google Photos, zip по 50 ГБ (максимальный размер части: чем меньше частей, тем реже фото и его JSON-метаданные разъезжаются по разным архивам). Скачивать все части резюмируемым способом, не голой вкладкой браузера — она молча бьёт большие файлы. И первая проверка ещё до всякого Immich:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls </span>takeout-<span class="k">*</span>.zip | <span class="nb">wc</span> <span class="nt">-l</span>    <span class="c"># число частей = числу, которое назвал Google</span>
<span class="k">for </span>f <span class="k">in </span>takeout-<span class="k">*</span>.zip<span class="p">;</span> <span class="k">do </span>unzip <span class="nt">-t</span> <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> <span class="o">&gt;</span>/dev/null <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">"OK </span><span class="nv">$f</span><span class="s2">"</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">"CORRUPT </span><span class="nv">$f</span><span class="s2">"</span><span class="p">;</span> <span class="k">done</span>
</code></pre></div></div>

<p>Битую часть — перекачать, пока ссылки живы (~7 дней). Пропущенная средняя часть молча теряет тысячи фото. Архивы <strong>не распаковывать</strong>: инструмент импорта читает zip напрямую и матчит фото↔JSON↔альбом по путям через все части сразу.</p>

<h4 id="immich-go-импорт-которому-нельзя-верить-на-слово">immich-go: импорт, которому нельзя верить на слово</h4>

<p>Заливает архив <a href="https://github.com/simulot/immich-go">immich-go</a> — специализированный инструмент именно для Takeout. Он берёт дату съёмки, GPS и альбомы из JSON-сайдкаров Google (родной <code class="language-plaintext highlighter-rouge">immich upload</code> этого не умеет — читает только EXIF и теряет всё, что Google хранил отдельно):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># сначала dry-run — прочитать сводку: uploaded / duplicate / unmatched / errors</span>
immich-go upload from-google-photos <span class="nt">--server</span><span class="o">=</span><span class="s2">"</span><span class="nv">$IMMICH_SERVER</span><span class="s2">"</span> <span class="nt">--api-key</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KEY</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--dry-run</span> takeout-<span class="k">*</span>.zip

<span class="c"># реальный импорт; идемпотентный — при обрыве просто повторить</span>
immich-go upload from-google-photos <span class="nt">--server</span><span class="o">=</span><span class="s2">"</span><span class="nv">$IMMICH_SERVER</span><span class="s2">"</span> <span class="nt">--api-key</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KEY</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--session-tag</span> <span class="nt">--on-errors</span><span class="o">=</span><span class="k">continue</span> <span class="nt">--pause-immich-jobs</span><span class="o">=</span><span class="nb">true</span> <span class="se">\</span>
  <span class="nt">--concurrent-tasks</span><span class="o">=</span>8 <span class="nt">--client-timeout</span><span class="o">=</span>60m takeout-<span class="k">*</span>.zip

<span class="c"># добрать файлы, чей JSON Google переименовал (по умолчанию они МОЛЧА пропускаются!)</span>
immich-go upload from-google-photos <span class="nt">--include-unmatched</span> <span class="nt">--session-tag</span> <span class="se">\</span>
  <span class="nt">--server</span><span class="o">=</span><span class="s2">"</span><span class="nv">$IMMICH_SERVER</span><span class="s2">"</span> <span class="nt">--api-key</span><span class="o">=</span><span class="s2">"</span><span class="nv">$KEY</span><span class="s2">"</span> takeout-<span class="k">*</span>.zip
</code></pre></div></div>

<p><strong>Логу immich-go верить нельзя</strong> («uploaded successfully» при недоехавших ассетах). Единственное доказательство полноты — счётчик самого Immich плюс идемпотентный повтор. У меня это выглядело так: первый прогон — 17 165 ассетов, второй добрал 51, <strong>третий — ноль новых</strong>. Ноль на повторе и есть определение «всё доехало». Итог: 17 212 ассетов (16 651 фото + 561 видео), все альбомы на месте. Сами прогоны, сверки счётчиков и повторы гонял Клод прямо на сервере — я читал отчёты; флаги в командах выше выстраданы именно так.</p>

<p>Отдельно — то, чего в Takeout <strong>нет вообще</strong>, и что при удалении из Google пропало бы навсегда: Locked Folder и фотографии, которые <em>другие люди</em> добавляли в общие альбомы. Их надо спасать руками до любой очистки (сохранить к себе → залить <code class="language-plaintext highlighter-rouge">from-folder</code>). У меня Locked Folder оказался пуст, а чужие фото я решил не тянуть — но проверить это надо явно, а не обнаружить постфактум.</p>

<h4 id="mega-архив-дедуп-до-заливки-а-не-после">MEGA-архив: дедуп до заливки, а не после</h4>

<p>Второй источник — 49 ГБ неразобранного личного архива из MEGA: выгрузки со старых телефонов, свадьба друга с зеркалки, скриншоты, арт, сканы документов. Лить такое в ленту как есть — испортить её навсегда, поэтому порядок другой:</p>

<ol>
  <li>
    <p><strong>Перцептивный дедуп до заливки.</strong> Не по байтам — по картинке: pHash с порогом Hamming ≤ 6 ловит один и тот же кадр в разных пережатиях. 938 дублей отсеялось ещё на ноутбуке. Техническая деталь, без которой ничего не работает: перед хэшированием обязателен <code class="language-plaintext highlighter-rouge">exif_transpose</code> — иначе портретное фото, у которого поворот записан в EXIF, даёт расстояние 26 вместо 0 к собственной копии.</p>
  </li>
  <li>
    <p><strong>Сортировка по судьбе.</strong> Живые фото и видео — в ленту. Скриншоты (771), картинки/арт (240), сканы и документы (99) — в альбомы с <code class="language-plaintext highlighter-rouge">visibility: archive</code>: они есть, ищутся, но ленту воспоминаний не засоряют.</p>
  </li>
  <li>
    <p><strong>Заливка и серверный дедуп.</strong> Immich сам отбрасывает байт-идентичное (часть архива пересекалась с Takeout). Из 6 639 файлов staging новыми легли 4 875.</p>
  </li>
</ol>

<p>Итог наполнения: <strong>22 087 ассетов, 30 альбомов, 139 ГБ</strong>. И только теперь можно говорить об очистке Google. При этом конечно все это делал ИИ, скрипты накидал, тут описан только принцип.</p>

<h4 id="очистка-google--после-трёх-сверок">Очистка Google — после трёх сверок</h4>

<p>Перед удалением — три независимые проверки, каждая ловит свой класс потерь:</p>

<ul>
  <li>
    <p><strong>Импорт полон:</strong> повторный прогон immich-go по Takeout = 0 новых; staging MEGA = 0 новых. Всё, что есть в источниках, есть на сервере.</p>
  </li>
  <li>
    <p><strong>Метаданные забраны:</strong> ~1,9 тыс. фото с GPS в JSON-сайдкарах ≈ столько же с GPS в Immich. Выборочно по всем годам: дата съёмки — не дата импорта, Live Photos играют, альбомы на месте.</p>
  </li>
  <li>
    <p><strong>Корзина не больше копии:</strong> после удаления в корзине Google оказалось 17 080 объектов при 17 212 в Immich. Удалённого не может быть больше, чем сохранённого; разница объяснена (Live Photo у Google — один объект, у Immich — кадр + видео, два ассета).</p>
  </li>
</ul>

<p>Само удаление из веб-интерфейса Google Photos — отдельная песня: «выбрать всё» для большой библиотеки там не существует или я не нашел, а массовый Shift-клик в их SPA не работает — интерфейс виртуализирован и мертвеет на массовых операциях. Я поручил это Клоду — он написал скрипт-демона, который через DevTools-протокол управляет моим же залогиненным браузером и делает то же, что делал бы я: выделяет фото, жмёт «В корзину», подтверждает диалоги. Партиями по паре десятков, с человеческими паузами и джиттером, с автостопом при любом намёке на капчу и сторожем-вотчдогом сверху. Семнадцать тысяч фотографий ИИ выкликивал <strong>два дня</strong> — фоном, пока я занимался своими делами и изредка поглядывал на счётчик корзины. Кстати, о счётчике: своему демону он тоже не верил не зря — счётчик скрипта в итоге наврал, полноту очистки подтвердила только пустая библиотека плюс число объектов в корзине. Ключевое: удалённое падает в корзину Google и лежит там <strong>60 дней</strong> — это последняя страховка, и её не трогаем до конца окна. Takeout-архивы (88 ГБ) и MEGA-исходники (27 ГБ) остаются холодной копией на своём диске навсегда — они больше не скачаются. Мне кажется Google специально сделал удаление таким сложным что бы человек забил, ИИ ~1200 раз прогонял цикл, падал и пробовал снова и цикл это много кликов.</p>

<h3 id="геолокация-вернуть-то-что-камера-не-записала">Геолокация: вернуть то, что камера не записала</h3>

<p>Та самая «другая история». После импорта GPS был примерно у <strong>2 тысяч</strong> фото из 22 — остальное снято до эпохи геотегов или телефонами с выключенным GPS. А хотелось карту жизни (в immich прям можно посмотреть карту). К сожалению данные старой слежки Google удалил, я бы по времени мог геотегировать. Сначала начали без спроса следить, а потом так же без спроса удалили собранное, это одна из причин почему я решил все перевести в личное облако.</p>

<p>Восстанавливал слоями, от точного к грубому, и с железным правилом: <strong>фото с настоящими координатами не трогаются вообще</strong>, а каждый восстановленный геотег несёт в описании пометку об источнике и точности — «геотег по альбому», «интерполяция по времени ≤6ч», «по событию календаря». Через год я смогу отличить правду камеры от моей реконструкции одним взглядом (и скриптом — по регулярке).</p>

<ul>
  <li>
    <p><strong>По альбомам-местам:</strong> альбомы, названные городом или страной («Город N: путешествие»), сами говорят, где сняты — фото без GPS внутри получают координаты этого места, с точностью «город» или «регион». ~1 300 фото. Такие альбомы вел Google Photo сам.</p>
  </li>
  <li>
    <p><strong>Интерполяция по времени ≤ 6 часов:</strong> фото без GPS берёт координаты ближайшего по времени фото с настоящим GPS. Порог именно 6 часов, не сутки — перелёты: утром одна страна, вечером другая. И якорями служат только настоящие координаты плюс альбомные — интерполяция от интерполяции запрещена, иначе ошибка едет цепочкой.</p>
  </li>
  <li>
    <p><strong>Хронология из Takeout-активности:</strong> самый неожиданный источник. «Мои действия» Google помнят каждый построенный маршрут и каждый просмотр карты с координатами прямо в URL — из этого, календаря и реальных EXIF собралась хронология в 15,6 тыс. временных якорей. По сути я восстановил где и чем занимался начиная с 2009 года, даже с учетом того, что Google слежку удалил. Для маршрутов это «в момент T я был здесь», для просмотров карт честнее «смотрел сюда» — и в пометке точности так и написано: «смотрел» ≠ «был». Прогон по хронологии дал ещё 10,8 тыс. геотегов: ~2,7 тыс. по якорям ≤ 6 ч и ~8,1 тыс. по периодам проживания — «в эти годы я жил в этом городе», city-level, с соответствующей пометкой. Перед применением — обязательная проверка: пересечение со множеством фото, у которых координаты уже есть, равно нулю (скрипт останавливается, если не так).</p>
  </li>
  <li>
    <p><strong>Вшивание в файлы:</strong> координаты в базе Immich — это хорошо, но файл должен быть самодостаточным (следующая миграция скажет спасибо). Утилита immich-exif пишет восстановленный GPS прямо в EXIF и перезаливает файл; оригиналы уезжают в корзину Immich на 30 дней. Вшиты пока ранние слои — альбомные и интерполяция; свежий хронологический живёт в базе, его вшивание тем же механизмом у меня в списке.</p>
  </li>
</ul>

<p>Итог: GPS-покрытие с ~2 до <strong>19,5 тыс. ассетов из 22</strong>. Карта в Immich из пары городов превратилась в маршрут жизни: переезды, командировки, отпуска. А совсем размытые слои — самые ранние годы, где даже город под вопросом, — я сознательно <strong>не</strong> применял: лучше честное «неизвестно», чем красивое враньё.</p>

<p>Всю эту археологию — разбор активности, сверки, скрипты с undo-списками — делал Клод по принципу «сначала отчёт, что и сколько затронет, потом моё подтверждение, потом правка».</p>

<h4 id="форк-ради-одной-буквы-как-не-потерять-фото-при-вшивании">Форк ради одной буквы: как не потерять фото при вшивании</h4>

<p>Со вшиванием не обошлось без форка. <a href="https://github.com/Majorfi/immich-exif">immich-exif</a> гоняет под капотом exiftool, и первый же прогон засыпал лог отказами — 475 штук, все на кадрах с зеркалки:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Warning: Truncated NikonScanIFD directory. IFD dropped.
Error: [minor] Bad NikonScanIFD offset for Nikon_Scan_0x002a
0 image files updated    1 files weren't updated due to errors
</code></pre></div></div>

<p>Разбор показал: в maker notes этих JPEG лежит фирменный под-каталог NikonScanIFD с битым смещением — наследие какого-то софта, который когда-то в жизни этих файлов неаккуратно переписал EXIF. Сам exiftool честно помечает ошибку как <code class="language-plaintext highlighter-rouge">[minor]</code>: данные не читаются, но они и не нужны — это метаданные древней никоновской программы, не изображение. Однако по умолчанию он отказывается <strong>записывать</strong> файл, в котором видит хоть какую-то ошибку, даже минорную. Безопасный дефолт, уважаю — но для меня он означал «сотни кадров остаются без геотега в файле навсегда», причём именно тех, ради которых всё затевалось.</p>

<p>У exiftool на этот случай есть штатный флаг <code class="language-plaintext highlighter-rouge">-m</code> (ignore minor errors): пиши, что просят, а битый мусорный под-каталог выброси. Проблема — immich-exif не умеет пробрасывать произвольные аргументы в exiftool. Пришлось форкнуть и добавить одну букву в оба вызова в <code class="language-plaintext highlighter-rouge">src/exif/tool.go</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// чтение тегов:</span>
<span class="n">cmdArgs</span> <span class="o">:=</span> <span class="nb">append</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">"-m"</span><span class="p">},</span> <span class="n">exifArgs</span><span class="o">...</span><span class="p">)</span>
<span class="c">// запись:</span>
<span class="n">cmdArgs</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">cmdArgs</span><span class="p">,</span> <span class="s">"-m"</span><span class="p">,</span> <span class="s">"-overwrite_original"</span><span class="p">,</span> <span class="s">"--"</span><span class="p">,</span> <span class="n">filePath</span><span class="p">)</span>
</code></pre></div></div>

<p>Пересобрал образ — и весь класс ошибки исчез: 475 никоновских отказов превратились в ноль, финальный прогон закончился строкой <code class="language-plaintext highlighter-rouge">Done: 451 succeeded, 0 failed</code>. Честности ради: нашлась ещё пара файлов с другой, уже настоящей поломкой (битая ссылка на встроенное превью в IFD1) — их не спас даже <code class="language-plaintext highlighter-rouge">-m</code>, и я оставил их в покое: геотег у них есть в базе, а чинить чужую битую структуру EXIF ради вшивания — уже перебор. Почему это не страшно: пиксели exiftool не трогает вообще, «теряется» только под-каталог, который и так был нечитаемым мусором. Плюс механизм сам по себе страхующий: сначала dry-run с планом правок, а запись идёт через re-upload — старый файл уезжает в корзину Immich на 30 дней, так что даже неудачная запись физически не может ничего уничтожить. И да: перед тем как дать стороннему инструменту API-ключ от всего архива и право перезаливать файлы, я попросил ИИ прочитать его исходники целиком — образ у меня так и называется, <code class="language-plaintext highlighter-rouge">immich-exif:audited</code>. Это, кстати, новое в век ИИ, я теперь все утилитки, а так же малоизвестные контейнеры прошу ИИ по исходникам анализировать и проводить аудит на предмет утечек и безопасности, скомандовать не сложно, а безопасность внутренней инфраструктуры подтягивается выше чем не читать вовсе.</p>

<h3 id="грабли">Грабли</h3>

<p>Места, где ИИ потратил время (а однажды — чуть не потерял данные), чтобы вы не потратили.</p>

<ol>
  <li>
    <p><strong>Геотег до окончания metadataExtraction затирает настоящий GPS.</strong> Главная грабля, единственная, где реально пострадали данные. Залил свадьбу (зеркалка с GPS-модулем), сразу проставил альбому координаты центра города — а Immich ещё не успел дочитать EXIF. Ручной геотег победил: настоящие покадровые координаты 1041 фото затёрлись в базе. Хуже того — <code class="language-plaintext highlighter-rouge">refresh-metadata</code> вручную проставленное <strong>не</strong> перезаписывает: файл говорит одно, база другое, и штатного пути назад нет. Лечение — delete + повторная заливка с ожиданием, пока <code class="language-plaintext highlighter-rouge">metadataExtraction</code> станет idle, матчинг файлов к ассетам по checksum. Порядок навсегда: залить → дождаться метаданных → только потом геотегать.</p>
  </li>
  <li>
    <p><strong>Логам инструментов миграции не верить.</strong> Полнота доказывается только счётчиком принимающей стороны и идемпотентным повтором до нуля новых. Это спасло от «успешно залито» при реально недоехавших файлах.</p>
  </li>
  <li>
    <p><strong>pHash без</strong> <strong><code class="language-plaintext highlighter-rouge">exif_transpose</code></strong> <strong>— мусор.</strong> Портретные фото хранят поворот в EXIF; без нормализации ориентации перцептивный хэш не совпадает у файла с самим собой.</p>
  </li>
  <li>
    <p><strong>Live Photos считаются дважды.</strong> У Google это один объект, у Immich — кадр + видео. Пока не знаешь — счётчики «не сходятся» и полчаса паники при сверке целостности.</p>
  </li>
  <li>
    <p><strong>ML под non-root молча деградирует.</strong> У uid 1000 в контейнере нет HOME → кэши моделей пытаются писаться в <code class="language-plaintext highlighter-rouge">/</code> → нужно <code class="language-plaintext highlighter-rouge">HOME=/cache</code>. Named-том кэша принадлежит root → uid 1000 туда не пишет → bind-mount в свою папку. И <code class="language-plaintext highlighter-rouge">group_add: ["993"]</code> для render-ноды. Все три — из серии «контейнер зелёный, фичи нет».</p>
  </li>
  <li>
    <p><strong>Android + mTLS: две мины подряд.</strong> Первая: кнопка выбора сертификата в приложении Immich ничего не делает, пока <code class="language-plaintext highlighter-rouge">.p12</code> не импортирован в <strong>системное</strong> хранилище Android (Настройки → Безопасность → Учётные данные); кнопка импорта в самом приложении выглядит рабочей, но мертва. Вторая: <code class="language-plaintext highlighter-rouge">.p12</code> должен быть в legacy-формате (PBE-SHA1-3DES) — OpenSSL 3 по умолчанию пакует его в AES-256, который Android импортирует молча-неуспешно, но тут я не уверен, кажется работало и с новыми форматами, но для информации.</p>
  </li>
  <li>
    <p><strong>API-нюансы Immich v3, о которые бьются скрипты:</strong> поисковый <code class="language-plaintext highlighter-rouge">search/metadata</code> возвращает <code class="language-plaintext highlighter-rouge">exifInfo: null</code> (GPS брать из <code class="language-plaintext highlighter-rouge">map/markers</code>, полный ассет — из <code class="language-plaintext highlighter-rouge">GET /assets/{id}</code>); описание живёт в <code class="language-plaintext highlighter-rouge">exifInfo.description</code>, не на верхнем уровне; массовый <code class="language-plaintext highlighter-rouge">PUT /api/assets</code> помечен deprecated, но он единственный рабочий способ bulk-правок — поэтому версия в <code class="language-plaintext highlighter-rouge">.env</code> держится на мажорном канале <code class="language-plaintext highlighter-rouge">v3</code> (не <code class="language-plaintext highlighter-rouge">release</code>), а обновление — осознанный шаг с дампом БД, а не автопул.</p>
  </li>
  <li>
    <p><strong>Тяжёлые операции — на сервере, не через туннель с ноутбука.</strong> Заливка десятков гигабайт через хрупкий туннель (сон ноутбука, обрыв Wi-Fi) виснет и рвётся. Всё тяжёлое гонится на самом CT против localhost. И рабочие файлы скриптов — не в <code class="language-plaintext highlighter-rouge">/tmp</code>: это tmpfs, ребут стирает (проверено на себе).</p>
  </li>
</ol>

<h3 id="что-в-итоге">Что в итоге</h3>

<ul>
  <li>
    <p><strong>Свой Google Photos.</strong> Immich с лентой, альбомами, картой, поиском «закат на море» (не проверял) и лицами (проверял) — на домашнем сервере, ML на интегрированной видеокарте мини-ПК. Переживает ребут без рук.</p>
  </li>
  <li>
    <p><strong>22 087 ассетов из двух источников</strong> — Google Photos и многолетний MEGA-хлам — слиты, дедуплицированы и разобраны: живое в ленте, скриншоты и сканы в архивных альбомах.</p>
  </li>
  <li>
    <p><strong>Ни одного потерянного кадра, и это доказано</strong>, а не «вроде всё скопировалось»: идемпотентные прогоны до нуля новых, сверка метаданных, корзина ≤ копии. Google очищен только после всех трёх проверок, страховки (корзина 60 дней, Takeout, MEGA-исходники, дампы БД, RAID, снапшоты) живут до сих пор.</p>
  </li>
  <li>
    <p><strong>География восстановлена честно:</strong> GPS-покрытие с 2 до 19,5 тысяч фото, каждый восстановленный геотег помечен источником и точностью, настоящие координаты не тронуты, ранние слои уже вшиты в сами файлы.</p>
  </li>
  <li>
    <p><strong>Снаружи сервера не существует.</strong> Только :443 с обязательным клиентским сертификатом; у телефона — узкий API-хост без админки. Внутри всё под обычным пользователем в unprivileged-контейнере.</p>
  </li>
</ul>

<p>Рутину — скрипты дедупа, разбор Takeout-активности, сверки, массовое удаление из Google — делал ИИ; решения «что удаляем, что геотегаем, где порог» оставались за мной, и перед каждой правкой я получал отчёт, что именно она затронет. Для музыки такой режим был удобством — для фотографий он обязателен: это единственные данные, которые не скачаешь заново, хотя сам архив я конечно все равно сохранил в исходном виде навсегда.</p>

<p>P.S. В итоге я перенес <a href="/blog/2026/07/16/proxmox-music-streaming.html">музыку</a> домой и фото из хранилища гугла домой, а после этого получил письмо от гугла, что по новым правилам все данные бэкапов и прочего теперь будут занимать место в хранилище. Они этим должны были спровоцировать меня подписку дороже купить, но у меня в хранилище теперь занято на самой мелкой подписке меньше трети и я обдумываю, а не выкинуть ли мне подписку вовсе. Особо обидно, что они за мной следили годами, а когда надо стало мне оказалось, что все удалили без спроса.</p>]]></content><author><name></name></author><category term="self-hosted" /><category term="opensourse" /><category term="immich" /><category term="photos" /><category term="google photos" /><summary type="html"><![CDATA[В прошлой статье я писал как перенести свою музыку себе домой, а это руководство для того, кто решил забрать свой фотоархив домой. От пустого LXC-контейнера до работающего Immich: свой «Google Photos» с умным поиском и лицами на встроенном GPU, доступ с телефона из любой точки мира — и перенос семнадцати тысяч фотографий из Google без потери единого кадра и с восстановлением геолокации.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/proxmox-photo-hosting/cover.png" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/proxmox-photo-hosting/cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Личное облако на Proxmox: как собрать себе музыкальный стриминговый сервис</title><link href="https://3draven.github.io/blog/2026/07/16/proxmox-music-streaming.html" rel="alternate" type="text/html" title="Личное облако на Proxmox: как собрать себе музыкальный стриминговый сервис" /><published>2026-07-16T20:15:44+00:00</published><updated>2026-07-16T20:15:44+00:00</updated><id>https://3draven.github.io/blog/2026/07/16/proxmox-music-streaming</id><content type="html" xml:base="https://3draven.github.io/blog/2026/07/16/proxmox-music-streaming.html"><![CDATA[<p><img src="/blog/assets/images/proxmox-music-streaming/cover.png" alt="" /></p>

<p>Это руководство для того, кто решил поднять всё сам. От пустого LXC-контейнера до работающего стека: свой стриминг, поиск и скачивание музыки с телефона, приватность через VPN и рекомендации «по звуку». Разбираем каждый из двенадцати сервисов — что ставим, зачем и как настроить.</p>

<h3 id="с-чего-всё-началось">С чего всё началось</h3>

<p>YouTube Music я использую довольно давно и с подпиской, полез чистить «Понравившееся» — а часть треков оттуда просто исчезла: где-то удалили клип, где-то канал в бане, где-то правообладатель передумал. Плейлист, который я собирал годами, тихо усыхал, и меня не спросили. Причем у меня есть подписка. Ну и обойдусь.</p>

<p>Заменить стриминг «включил — играет любой трек мира» самохостинг не может, каталог из десятков миллионов треков дома не поднять. Задача другая — забрать под контроль то, что реально ценно: свою коллекцию, свою курацию и механику «услышал → добавил → получил похожее» в пару кликов. Ну и, конечно, <a href="https://www.youtube.com/watch?v=2hSKxFJbE_8">мы не пираты</a>.</p>

<h3 id="чего-мы-хотим">Чего мы хотим</h3>

<p>Я не слежу за артистами и не качаю дискографии — мне нужно, чтобы играла <em>моя</em> коллекция, чтобы услышанный трек добавлялся одной кнопкой, и чтобы система подсовывала похожее. Отсюда цели:</p>

<ol>
  <li>
    <p><strong>Своя коллекция на своём диске.</strong> Ядро — самохостовый аудио-сервер (Navidrome, протокол OpenSubsonic): стриминг, плейлисты, рейтинги. Файлы у меня и не исчезают от чужих решений.</p>
  </li>
  <li>
    <p><strong>Поиск и добавление трека с телефона.</strong> Услышал — за пару тапов в коллекции, теггировано и разложено. Без «подойди к ноутбуку, запусти Х».</p>
  </li>
  <li>
    <p><strong>Приватность, домашний IP не светится.</strong> Всё внешнее приобретение — через VPN с fail-closed killswitch. Soulseek (P2P-раздача — главный DMCA-вектор) живёт целиком внутри сетевого namespace VPN.</p>
  </li>
  <li>
    <p><strong>Перенос курации.</strong> Годы лайков, рейтингов, плейлистов и «мёртвые» треки (те, что Google уже удалил) — не потерять, а перенести в нативные механизмы Navidrome.</p>
  </li>
  <li>
    <p><strong>Sonic-рекомендации.</strong> Локальный анализ звука («дай похожее на этот трек») плюс weekly-рекомендации по вкусу для открытия нового.</p>
  </li>
  <li>
    <p><strong>В сумме — замена YTM.</strong> На телефоне один клиент (Symfonium под Android), в нём моя музыка, лайки, поиск-добавление и радио. Zero-subscription — ни одной активной подписки.</p>
  </li>
</ol>

<h3 id="архитектура">Архитектура</h3>

<p>Одна мысль, из которой растёт весь стек:</p>

<blockquote>
  <p><strong>Navidrome читает каталог</strong> <strong><code class="language-plaintext highlighter-rouge">/music</code></strong> <strong>и строит библиотеку по тегам внутри файлов. SoulSync — это то, что</strong> <strong><code class="language-plaintext highlighter-rouge">/music</code></strong> <strong>наполняет.</strong> Остальное — обвязка вокруг этих двух ролей.</p>
</blockquote>

<p>Держа это в голове, двенадцать контейнеров складываются в понятную трубу <strong>источники → SoulSync →</strong> <strong><code class="language-plaintext highlighter-rouge">/music</code></strong> <strong>→ Navidrome → телефон</strong>. Ядро — ровно два сервиса; прочие десять — либо приватность, либо «умные» фичи, либо клей.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      ТЕЛЕФОН — Symfonium + клиентский серт .p12
                     │ HTTPS :443, mTLS-гейт (нет серта → connection refused)
                     ▼
   edge-Caddy:  tls { client_auth trust music-CA }   nvd.example.com
                     │ reverse_proxy → внутренний caddy (Host сохраняется)
   ══════ LXC «music» (unprivileged, Docker) ══════
                     ▼
   caddy (внутренний ingress :80) — Host-роутинг локальных UI
                     ▼
   navidrome :4533  ◄── плагин audiomuseai.ndp (sonicSimilarity)
   ЧИТАЕТ /music ПО ТЕГАМ           ▲ анализ звука
        ▲                    audiomuse ×4 (flask+worker+postgres+redis)
        │ читает /music (ro)
        │ ПИШЕТ (file_organization=ON)
   SoulSync :8008 (+ PWA на телефон)
        │ всё внешнее — через VPN
        ▼
   gluetun (WireGuard-VPN, killswitch fail-closed)
     ├─ HTTP-прокси :8888 ← YouTube/Deezer
     └─ netns: slskd (Soulseek P2P), prowlarr+qbit (торренты, opt-in)
     egress = VPN. Домашний/VPS IP наружу не светится.

   explo — раз в неделю пишет плейлист-рекомендацию в Navidrome
</code></pre></div></div>

<p>Со схемы важно прочитать две вещи. Первое: <strong>телефон ходит только К Navidrome, «сверху вниз»</strong> — у VPN нет входящего, приватность телефонного трафика держит mTLS на edge, а VPN закрывает <em>исходящий</em> трафик качалок. Второе: <strong><code class="language-plaintext highlighter-rouge">/music</code></strong> <strong>— одна физическая папка</strong> (bind-mount), в которую SoulSync пишет, а Navidrome читает; никакого копирования, никакого промежуточного каталога. Про mTLS важно отметить, что это Caddy reverse proxy и в моих прошлых статьях описано, что это самый край домашней инфры, торчащий в интернет, второй Caddy из текста ниже, это внутри LXC просто для удобства. Внешний Caddy заставить просить mTLS мне нужно было потому, что дырявым музыкальным веб-серверам я не доверяю, а проигрыватель Symfonium как раз поддерживает аутентификацию по сертификату и делает это более надежный Caddy. Не забудьте только в телефон сертификат установить, на андроиде, в настройках. Расписывать как сгенерировать сертификат и установить я не буду, разберетесь. Тогда какие бы дырявые ни были музыкальные сервисы, защищает их внешний reverse proxy.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Контейнер</td>
      <td>Образ</td>
      <td>Роль</td>
    </tr>
    <tr>
      <td><strong>navidrome</strong></td>
      <td><code class="language-plaintext highlighter-rouge">deluan/navidrome:0.63.2</code></td>
      <td>Ядро. OpenSubsonic-сервер: <code class="language-plaintext highlighter-rouge">/music</code> по тегам, стриминг, плейлисты, рейтинги</td>
    </tr>
    <tr>
      <td><strong>soulsync</strong></td>
      <td><code class="language-plaintext highlighter-rouge">boulderbadgedad/soulsync:3.0.1</code></td>
      <td>Ядро. Поиск+скачивание (YouTube/Soulseek/торренты/Deezer/…), пишет в <code class="language-plaintext highlighter-rouge">/music</code>, тегирует. PWA на телефон</td>
    </tr>
    <tr>
      <td><strong>slskd</strong></td>
      <td><code class="language-plaintext highlighter-rouge">slskd/slskd:latest</code></td>
      <td>Soulseek P2P — источник редкого лосслесса. В netns gluetun</td>
    </tr>
    <tr>
      <td><strong>gluetun</strong></td>
      <td><code class="language-plaintext highlighter-rouge">qmcgaw/gluetun:latest</code></td>
      <td>VPN-шлюз (WireGuard) + killswitch. Оборачивает качалки; HTTP-прокси <code class="language-plaintext highlighter-rouge">:8888</code></td>
    </tr>
    <tr>
      <td><strong>prowlarr / qbittorrent</strong></td>
      <td><code class="language-plaintext highlighter-rouge">lscr.io/linuxserver/*</code></td>
      <td>Торрент-индексеры + клиент. Профиль <code class="language-plaintext highlighter-rouge">torrents</code>, opt-in. В netns gluetun</td>
    </tr>
    <tr>
      <td><strong>audiomuse</strong> (flask + worker)</td>
      <td><code class="language-plaintext highlighter-rouge">ghcr.io/neptunehub/audiomuse-ai:2.6.2</code></td>
      <td>Sonic-анализ звука (Essentia + CLAP-эмбеддинги): похожие треки, плейлисты</td>
    </tr>
    <tr>
      <td><strong>audiomuse-db / -redis</strong></td>
      <td><code class="language-plaintext highlighter-rouge">postgres:15</code>, <code class="language-plaintext highlighter-rouge">redis:7</code></td>
      <td>Хранилище эмбеддингов и очередь задач анализа</td>
    </tr>
    <tr>
      <td><strong>explo</strong></td>
      <td><code class="language-plaintext highlighter-rouge">ghcr.io/lumepart/explo:latest</code></td>
      <td>Weekly-рекомендации ListenBrainz → плейлист в Navidrome</td>
    </tr>
    <tr>
      <td><strong>caddy</strong></td>
      <td><code class="language-plaintext highlighter-rouge">caddy:2-alpine</code></td>
      <td>Внутренний reverse-proxy: Host-роутинг локальных UI, единая точка за edge-mTLS</td>
    </tr>
  </tbody>
</table>

<h3 id="установка">Установка</h3>

<h4 id="шаг-0-lxc--то-что-делается-только-с-хоста">Шаг 0. LXC — то, что делается только с хоста</h4>

<p>Стек живёт в отдельном unprivileged-LXC. Gluetun нужен туннельный интерфейс, который LXC по умолчанию не отдаёт:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># на хосте Proxmox, под root (117 — номер вашего CT)</span>
pct <span class="nb">set </span>117 <span class="nt">--dev0</span> /dev/net/tun
</code></pre></div></div>

<p>Дальше всё внутри контейнера и под обычным пользователем (у меня <code class="language-plaintext highlighter-rouge">user</code>, uid 1000) — root понадобится только для установки docker и systemd-юнита автозапуска. Это осознанное решение: чем меньше крутится под root, тем меньше поверхность, если один из образов окажется недобросовестным.</p>

<h4 id="шаг-1-каталоги--одно-дерево-один-владелец">Шаг 1. Каталоги — одно дерево, один владелец</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># внутри CT, под обычным пользователем</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/music-app/<span class="o">{</span>navidrome/plugins,soulsync/<span class="o">{</span>config,logs<span class="o">}</span>,slskd,prowlarr,qbit,caddy<span class="o">}</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/music-app/audiomuse/<span class="o">{</span>pgdata,redis-data,temp-flask,temp-worker,plugins-flask,plugins-worker<span class="o">}</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/music-app/explo/config
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/music-app/data/<span class="o">{</span>media/music,soulseek/downloads,torrents<span class="o">}</span>
<span class="nb">cd</span> ~/music-app
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">data/media/music</code> — это и есть тот самый <code class="language-plaintext highlighter-rouge">/music</code>, единственная папка с музыкой. Всё дерево принадлежит uid 1000; за этим стоит следить (см. грабли про root-owned каталоги).</p>

<h4 id="шаг-2-секреты--env-с-chmod-600">Шаг 2. Секреты — .env с chmod 600</h4>

<p>Пароли и ключи генерируем на месте и держим только в <code class="language-plaintext highlighter-rouge">.env</code>, не в compose и не в образах:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">{</span>
  <span class="nb">echo</span> <span class="s2">"TZ=Europe/Amsterdam"</span>
  <span class="nb">echo</span> <span class="s2">"PUID=1000"</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">"PGID=1000"</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">"UMASK=002"</span>
  <span class="nb">echo</span> <span class="s2">"AM_DB_PASSWORD=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 24<span class="si">)</span><span class="s2">"</span>          <span class="c"># Postgres AudioMuse</span>
  <span class="nb">echo</span> <span class="s2">"ND_PASSWORDENCRYPTIONKEY=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 24<span class="si">)</span><span class="s2">"</span> <span class="c"># шифрование паролей Navidrome</span>
  <span class="nb">echo</span> <span class="s2">"SLSK_USER=music-</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 4<span class="si">)</span><span class="s2">"</span>           <span class="c"># Soulseek-аккаунт заводится первым логином</span>
  <span class="nb">echo</span> <span class="s2">"SLSK_PASS=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 16<span class="si">)</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"EXPLO_UI_PASSWORD=</span><span class="si">$(</span>openssl rand <span class="nt">-hex</span> 8<span class="si">)</span><span class="s2">"</span>
<span class="o">}</span> <span class="o">&gt;&gt;</span> .env
<span class="nb">chmod </span>600 .env
</code></pre></div></div>

<p>Отдельно — приватный ключ WireGuard от вашего VPN-провайдера. Его вставляем через невидимый ввод, чтобы он не попал ни на экран, ни в history:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">read</span> <span class="nt">-rs</span> WGKEY
<span class="nb">printf</span> <span class="s1">'WG_PRIVATE_KEY=%s\n'</span> <span class="s2">"</span><span class="nv">$WGKEY</span><span class="s2">"</span> <span class="o">&gt;&gt;</span> .env
</code></pre></div></div>

<h4 id="шаг-3-docker-composeyml--весь-стек">Шаг 3. docker-compose.yml — весь стек</h4>

<p>Здесь есть одна сетевая ловушка, на которой легко потерять полдня. Контейнеры за <code class="language-plaintext highlighter-rouge">network_mode: service:gluetun</code> наследуют DNS gluetun и <strong>перестают резолвить docker-имена</strong> — SoulSync теряет связь с <code class="language-plaintext highlighter-rouge">navidrome</code> и <code class="language-plaintext highlighter-rouge">slskd</code>. Поэтому в туннель VPN мы сажаем только качалки (slskd, prowlarr, qbit), а SoulSync живёт в обычной сети (docker-DNS работает) и гонит наружу через HTTP-прокси gluetun. Ниже это уже учтено.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">music</span>

<span class="na">networks</span><span class="pi">:</span>
  <span class="na">default</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">music</span>
    <span class="na">ipam</span><span class="pi">:</span>
      <span class="na">config</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">subnet</span><span class="pi">:</span> <span class="s">172.28.0.0/24</span>   <span class="c1"># фиксируем подсеть, чтобы не пересечься с диапазоном VPN</span>

<span class="na">x-env</span><span class="pi">:</span> <span class="nl">&amp;common-env</span>
  <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
  <span class="na">PUID</span><span class="pi">:</span> <span class="s">${PUID}</span>
  <span class="na">PGID</span><span class="pi">:</span> <span class="s">${PGID}</span>
  <span class="na">UMASK</span><span class="pi">:</span> <span class="s">${UMASK}</span>

<span class="na">services</span><span class="pi">:</span>
  <span class="c1"># ── музыкальный сервер (наружу через edge-Caddy с mTLS) ──</span>
  <span class="na">navidrome</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">deluan/navidrome:0.63.2</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">navidrome</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">ND_SCANSCHEDULE</span><span class="pi">:</span> <span class="s">1h</span>
      <span class="na">ND_PLUGINS_ENABLED</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>           <span class="c1"># нужно для плагина AudioMuse</span>
      <span class="na">ND_PLUGINS_AUTORELOAD</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">ND_AGENTS</span><span class="pi">:</span> <span class="s2">"</span><span class="s">audiomuseai,deezer"</span>      <span class="c1"># audiomuseai первым — sonic-похожесть</span>
      <span class="na">ND_DEVARTISTINFOTIMETOLIVE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1s"</span>     <span class="c1"># не отдавать similar/radio из кэша</span>
      <span class="na">ND_PASSWORDENCRYPTIONKEY</span><span class="pi">:</span> <span class="s">${ND_PASSWORDENCRYPTIONKEY}</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./navidrome:/data</span>
      <span class="pi">-</span> <span class="s">./data/media/music:/music:ro</span>       <span class="c1"># библиотека — только на чтение</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── AudioMuse-AI: sonic-анализ (flask + worker + postgres + redis) ──</span>
  <span class="na">audiomuse</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/neptunehub/audiomuse-ai:2.6.2</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">audiomuse-ai-flask-app</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="nl">&amp;am-env</span>
      <span class="na">SERVICE_TYPE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">flask"</span>
      <span class="na">POSTGRES_HOST</span><span class="pi">:</span> <span class="s">audiomuse-db</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">audiomuse</span>
      <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">${AM_DB_PASSWORD}</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">audiomusedb</span>
      <span class="na">REDIS_URL</span><span class="pi">:</span> <span class="s">redis://audiomuse-redis:6379/0</span>
      <span class="na">TEMP_DIR</span><span class="pi">:</span> <span class="s">/app/temp_audio</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./audiomuse/temp-flask:/app/temp_audio</span>
      <span class="pi">-</span> <span class="s">./audiomuse/plugins-flask:/app/plugin/installed</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">audiomuse-db</span><span class="pi">,</span> <span class="nv">audiomuse-redis</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">audiomuse-worker</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/neptunehub/audiomuse-ai:2.6.2</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">audiomuse-ai-worker</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*am-env</span>
      <span class="na">SERVICE_TYPE</span><span class="pi">:</span> <span class="s2">"</span><span class="s">worker"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./audiomuse/temp-worker:/app/temp_audio</span>
      <span class="pi">-</span> <span class="s">./audiomuse/plugins-worker:/app/plugin/installed</span>
      <span class="pi">-</span> <span class="s">./data/media/music:/music:ro</span>       <span class="c1"># воркер читает аудио для анализа</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">audiomuse-db</span><span class="pi">,</span> <span class="nv">audiomuse-redis</span><span class="pi">]</span>
    <span class="na">cpus</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4.0"</span>                             <span class="c1"># анализ тяжёлый — ограничиваем, чтобы не душить соседей на хосте</span>
    <span class="na">mem_limit</span><span class="pi">:</span> <span class="s">4g</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">audiomuse-redis</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">redis:7-alpine</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">audiomuse-redis</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">volumes</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">./audiomuse/redis-data</span><span class="pi">:</span><span class="nv">/data</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">audiomuse-db</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:15-alpine</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">audiomuse-db</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">command</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">postgres"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">-c"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">unix_socket_directories=/tmp"</span><span class="pi">]</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">POSTGRES_USER</span><span class="pi">:</span> <span class="s">audiomuse</span>
      <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">${AM_DB_PASSWORD}</span>
      <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">audiomusedb</span>
    <span class="na">volumes</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">./audiomuse/pgdata</span><span class="pi">:</span><span class="nv">/var/lib/postgresql/data</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── VPN-шлюз: WireGuard + killswitch ──</span>
  <span class="na">gluetun</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">qmcgaw/gluetun:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">gluetun</span>
    <span class="na">cap_add</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">NET_ADMIN</span><span class="pi">]</span>
    <span class="na">devices</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">/dev/net/tun:/dev/net/tun"</span><span class="pi">]</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">5030:5030"</span>   <span class="c1"># slskd Web UI</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">8080:8080"</span>   <span class="c1"># qBittorrent (профиль torrents)</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">9696:9696"</span>   <span class="c1"># Prowlarr    (профиль torrents)</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">VPN_SERVICE_PROVIDER</span><span class="pi">:</span> <span class="s">nordvpn</span>        <span class="c1"># ваш провайдер</span>
      <span class="na">VPN_TYPE</span><span class="pi">:</span> <span class="s">wireguard</span>
      <span class="na">WIREGUARD_PRIVATE_KEY</span><span class="pi">:</span> <span class="s">${WG_PRIVATE_KEY}</span>
      <span class="na">SERVER_COUNTRIES</span><span class="pi">:</span> <span class="s">Switzerland</span>        <span class="c1"># DMCA-safe юрисдикция</span>
      <span class="na">SERVER_CATEGORIES</span><span class="pi">:</span> <span class="s">P2P</span>
      <span class="na">FIREWALL</span><span class="pi">:</span> <span class="s2">"</span><span class="s">on"</span>                       <span class="c1"># killswitch fail-closed: туннель упал → трафик дропается</span>
      <span class="na">HTTPPROXY</span><span class="pi">:</span> <span class="s2">"</span><span class="s">on"</span>                      <span class="c1"># :8888 для HTTP-качалок (SoulSync)</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── Soulseek-демон (целиком в туннеле VPN) ──</span>
  <span class="na">slskd</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">slskd/slskd:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">slskd</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">gluetun</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_healthy</span> <span class="pi">}</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">SLSKD_SLSK_USERNAME</span><span class="pi">:</span> <span class="s">${SLSK_USER}</span>
      <span class="na">SLSKD_SLSK_PASSWORD</span><span class="pi">:</span> <span class="s">${SLSK_PASS}</span>
      <span class="na">SLSKD_SLSK_LISTEN_PORT</span><span class="pi">:</span> <span class="s2">"</span><span class="s">50300"</span>      <span class="c1"># входящий порт для раздачи (иначе uploads=0)</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./slskd:/app</span>
      <span class="pi">-</span> <span class="s">./data:/data</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── SoulSync: поиск+скачивание (наружу через edge-Caddy) ──</span>
  <span class="na">soulsync</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">boulderbadgedad/soulsync:3.0.1</span>  <span class="c1"># конкретный тег, НЕ latest (проект очень активен — см. грабли)</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">soulsync-webui</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">PUID=1000</span>
      <span class="pi">-</span> <span class="s">PGID=1000</span>
      <span class="pi">-</span> <span class="s">UMASK=022</span>
      <span class="pi">-</span> <span class="s">TZ=${TZ}</span>
      <span class="pi">-</span> <span class="s">HTTP_PROXY=http://gluetun:8888</span>     <span class="c1"># внешнее (YouTube/Deezer) → VPN</span>
      <span class="pi">-</span> <span class="s">HTTPS_PROXY=http://gluetun:8888</span>
      <span class="pi">-</span> <span class="s">NO_PROXY=localhost,127.0.0.1,navidrome,gluetun,slskd.music,caddy,172.28.0.0/16,.music</span> <span class="c1"># внутреннее → напрямую; gluetun обязателен — иначе запросы к Prowlarr/qBit уйдут в прокси</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./soulsync/config:/app/config</span>
      <span class="pi">-</span> <span class="s">./soulsync/logs:/app/logs</span>
      <span class="pi">-</span> <span class="s">./data/soulseek/downloads:/app/downloads</span>
      <span class="pi">-</span> <span class="s">./data/media/music:/app/Transfer</span>   <span class="c1"># SoulSync пишет ПРЯМО в /music</span>
      <span class="pi">-</span> <span class="s">./data/torrents:/data/torrents</span>
      <span class="pi">-</span> <span class="s">soulsync_database:/app/data</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">slskd</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── Торренты (опционально, профиль torrents; в туннеле VPN) ──</span>
  <span class="na">prowlarr</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/prowlarr:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">prowlarr</span>
    <span class="na">profiles</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">torrents"</span><span class="pi">]</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">gluetun</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_healthy</span> <span class="pi">}</span> <span class="pi">}</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="nv">*common-env</span>
    <span class="na">volumes</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">./prowlarr</span><span class="pi">:</span><span class="nv">/config</span><span class="pi">]</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="na">qbittorrent</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">lscr.io/linuxserver/qbittorrent:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">qbittorrent</span>
    <span class="na">profiles</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">torrents"</span><span class="pi">]</span>
    <span class="na">network_mode</span><span class="pi">:</span> <span class="s2">"</span><span class="s">service:gluetun"</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">gluetun</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_healthy</span> <span class="pi">}</span> <span class="pi">}</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*common-env</span>
      <span class="na">WEBUI_PORT</span><span class="pi">:</span> <span class="s2">"</span><span class="s">8080"</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./qbit:/config</span>
      <span class="pi">-</span> <span class="s">./data:/data</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── Explo: weekly-рекомендации ListenBrainz → плейлист ──</span>
  <span class="na">explo</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/lumepart/explo:latest</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">explo</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">TZ</span><span class="pi">:</span> <span class="s">${TZ}</span>
      <span class="na">WEB_UI</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
      <span class="na">UI_USERNAME</span><span class="pi">:</span> <span class="s">admin</span>
      <span class="na">UI_PASSWORD</span><span class="pi">:</span> <span class="s">${EXPLO_UI_PASSWORD}</span>
      <span class="na">HTTP_PROXY</span><span class="pi">:</span> <span class="s">http://gluetun:8888</span>
      <span class="na">HTTPS_PROXY</span><span class="pi">:</span> <span class="s">http://gluetun:8888</span>
      <span class="na">NO_PROXY</span><span class="pi">:</span> <span class="s">localhost,127.0.0.1,navidrome,gluetun,slskd.music,caddy,172.28.0.0/16,.music</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./explo/config:/opt/explo/config</span>
      <span class="pi">-</span> <span class="s">./data/media/music/Explo:/data/</span>
      <span class="pi">-</span> <span class="s">./data/soulseek/downloads:/slskd/</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="na">gluetun</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_healthy</span> <span class="pi">}</span>
      <span class="na">navidrome</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_started</span> <span class="pi">}</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

  <span class="c1"># ── внутренний reverse-proxy (Host-роутинг локальных UI) ──</span>
  <span class="na">caddy</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">caddy:2-alpine</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">caddy</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1000:1000"</span>
    <span class="na">cap_add</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">NET_BIND_SERVICE</span><span class="pi">]</span>
    <span class="na">ports</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">80:80"</span><span class="pi">]</span>
    <span class="na">volumes</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">./caddy/Caddyfile</span><span class="pi">:</span><span class="nv">/etc/caddy/Caddyfile</span><span class="pi">:</span><span class="nv">ro</span><span class="pi">]</span>
    <span class="na">depends_on</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">gluetun</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">condition</span><span class="pi">:</span> <span class="nv">service_healthy</span> <span class="pi">}</span> <span class="pi">}</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">networks</span><span class="pi">:</span>
      <span class="na">default</span><span class="pi">:</span>
        <span class="na">aliases</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">nvd.music</span><span class="pi">,</span> <span class="nv">ss.music</span><span class="pi">,</span> <span class="nv">slskd.music</span><span class="pi">,</span> <span class="nv">audiomuse.music</span><span class="pi">]</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">soulsync_database</span><span class="pi">:</span>
</code></pre></div></div>

<h4 id="шаг-4-внутренний-caddy--маршрутизация-локальных-ui">Шаг 4. Внутренний Caddy — маршрутизация локальных UI</h4>

<p>Внутренний caddy отдаёт веб-морды сервисов по внутренним именам (снаружи их закрывает edge-Caddy с mTLS). Минимальный <code class="language-plaintext highlighter-rouge">caddy/Caddyfile</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nvd.example.com       { reverse_proxy navidrome:4533 }
ss.example.com        { reverse_proxy soulsync-webui:8008 }
audiomuse.example.com { reverse_proxy audiomuse-ai-flask-app:8000 }
slskd.example.com     { reverse_proxy gluetun:5030 }
</code></pre></div></div>

<p>Наружу эти хосты проксирует ваша edge-нода, добавляя mTLS (клиентский сертификат). Как устроен edge-слой с mTLS — тема отдельная; здесь важно, что телефон ходит на <code class="language-plaintext highlighter-rouge">nvd.example.com</code> за сертификатом, а всё остальное недоступно снаружи вовсе.</p>

<h4 id="шаг-5-запуск-и-проверка-что-vpn-держит">Шаг 5. Запуск и проверка, что VPN держит</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">-d</span>                      <span class="c"># ядро</span>
docker compose <span class="nt">--profile</span> torrents up <span class="nt">-d</span>   <span class="c"># плюс торренты, если нужны</span>
</code></pre></div></div>

<p>Прежде чем что-то качать — убедимся, что качалки реально ходят через VPN, а не мимо. Спрашиваем публичный IP <strong>изнутри туннеля</strong> (из контейнера, который в netns gluetun):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker <span class="nb">exec </span>qbittorrent sh <span class="nt">-c</span> <span class="s1">'wget -qO- https://ipinfo.io/ip'</span>
<span class="c"># → адрес вашего VPN-сервера. С реальным IP хоста совпасть НЕ должен.</span>
</code></pre></div></div>

<p>Это принципиально: спросив IP с хоста, вы увидите egress хоста и ничего не поймёте. Проверять надо именно из namespace VPN. Убьёте туннель (<code class="language-plaintext highlighter-rouge">docker stop gluetun</code>) — у slskd/qbit пропадёт сеть целиком: killswitch fail-closed, утечь нечему.</p>

<h4 id="шаг-6-автозапуск-после-ребута">Шаг 6. Автозапуск после ребута</h4>

<p>Чтобы стек поднимался сам, вешаем на хост CT systemd-юнит:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># /etc/systemd/system/music-app.service  (под root)
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/&lt;user&gt;/music-app
ExecStart=/usr/bin/docker compose --profile torrents up -d
User=&lt;user&gt;
[Install]
WantedBy=multi-user.target
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">systemctl enable --now music-app</code>. Вместе с <code class="language-plaintext highlighter-rouge">restart: unless-stopped</code> на контейнерах и <code class="language-plaintext highlighter-rouge">onboot=1</code> на самом LXC — три уровня «поднимись сам», потому что за «после ребута ничего не играет» никто не любит просыпаться.</p>

<h3 id="настройка-сервисов">Настройка сервисов</h3>

<p>Compose поднял контейнеры — теперь пройдёмся по веб-мордам и включим то, что не задаётся через окружение. Никаких ручных запросов к API: всё это тумблеры и поля в интерфейсах (но я это почти все не нажимал, только юзеров создавал, я попросил клода и он через API все сам нажал, рекомендую, а потом я его же попросил написать кнопки в текст статьи, они примерные).</p>

<p><strong>Navidrome</strong> (<code class="language-plaintext highlighter-rouge">nvd.example.com</code>). При первом входе создаём администратора. Библиотека уже примонтирована (<code class="language-plaintext highlighter-rouge">/music</code>), скан идёт по расписанию. Для sonic-похожести нужен плагин AudioMuse: кладём файл <code class="language-plaintext highlighter-rouge">audiomuseai.ndp</code> в <code class="language-plaintext highlighter-rouge">navidrome/plugins/</code> и в его настройках указываем адрес AudioMuse (<code class="language-plaintext highlighter-rouge">http://audiomuse-ai-flask-app:8000</code>). В compose агент уже включён первым (<code class="language-plaintext highlighter-rouge">ND_AGENTS=audiomuseai,deezer</code>) — этого достаточно, чтобы Navidrome начал отдавать «похожие треки».</p>

<p><strong>Soulseek</strong> (<code class="language-plaintext highlighter-rouge">slskd.example.com</code>). Аккаунт Soulseek заводится сам фактом первого логина с логином/паролем из <code class="language-plaintext highlighter-rouge">.env</code>. Проверяем, что демон подключился и что открыт порт раздачи — без входящего порта вы только качаете, но не раздаёте, а на Soulseek это влияет на репутацию.</p>

<p><strong>SoulSync</strong> (<code class="language-plaintext highlighter-rouge">ss.example.com</code>) — центральный пульт. Здесь всё через интерфейс:</p>

<ul>
  <li>
    <p><strong>Источники.</strong> Включаем нужные: Soulseek (подхватывается автоматически), YouTube (работает из коробки, качает через VPN-прокси). Для торрентов в настройках указываем адреса нашего Prowlarr и qBittorrent — SoulSync будет искать и там.</p>
  </li>
  <li>
    <p><strong>Профиль качества.</strong> Ставим режим <em>best_quality</em> (пулит кандидатов со всех источников и берёт лучший по факту, а не первый), cutoff — <em>FLAC 16-bit</em>, и включаем <em>replace lower quality</em> (замена файла на лучший на месте).</p>
  </li>
  <li>
    <p><strong>Обслуживание библиотеки (repair-джобы).</strong> Включаем те, что чинят метадату скачанного: <em>Unknown Artist Fixer</em>, <em>Metadata Gap Filler</em> (дотягивает MBID из MusicBrainz), <em>Fake Lossless Detector</em> (спектрально ловит псевдо-FLAC), <em>Quality Upgrade</em> (движок апгрейда до FLAC). Про важный нюанс их применения — в разделе «как работает».</p>
  </li>
  <li>
    <p><strong>Раскладка.</strong> Включаем <em>file organization</em> (<code class="language-plaintext highlighter-rouge">Артист/Альбом/Трек</code>) и отмечаем <code class="language-plaintext highlighter-rouge">/music</code> как постоянную библиотеку — тогда SoulSync пишет прямо в неё и не пытается растащить уже разложенное.</p>
  </li>
  <li>
    <p><strong>Телефон.</strong> SoulSync — это PWA: открываем <code class="language-plaintext highlighter-rouge">ss.example.com</code> в мобильном браузере и добавляем на домашний экран. Дальше поиск и скачивание — прямо из кармана. Интерфейс там так себе, но привыкнуть можно, а мне надо раз в месяц.</p>
  </li>
</ul>

<p><strong>AudioMuse</strong> (<code class="language-plaintext highlighter-rouge">audiomuse.example.com</code>). Проходим setup-wizard (указываем Navidrome и креды), запускаем первичный анализ библиотеки — воркер посчитает звуковые эмбеддинги, это разово и небыстро. Похожесть работает только по проанализированным трекам, так что дайте анализу закончиться. Чтобы плейлисты обновлялись сами — на странице Cron заводим две ночные задачи: <em>analysis</em> (проанализировать новьё) и <em>clustering</em> (пересобрать sonic-плейлисты и записать их прямо в Navidrome). После этого никаких действий от вас не требуется — плейлисты появляются в Symfonium как обычные.</p>

<p><strong>Explo.</strong> Указываем токен ListenBrainz, задаём расписание — раз в неделю он собирает рекомендацию «по вкусу» и кладёт плейлистом в Navidrome.</p>

<p>Повторю, я только в navidrome пользователя создал и попросил нейронку все через API сервисов настроить, она сама все и настроила за пару итерация проверок, что работает.</p>

<h3 id="как-это-работает">Как это работает</h3>

<h4 id="soulsync-от-услышал-до-играет">SoulSync: от «услышал» до «играет»</h4>

<p>Вы ищете трек в PWA, SoulSync опрашивает источники и в режиме <em>best_quality</em> берёт лучшую версию, скачивает её, тегирует и раскладывает в <code class="language-plaintext highlighter-rouge">Артист/Альбом/Трек</code> прямо в <code class="language-plaintext highlighter-rouge">/music</code>. Через минуту Navidrome его проиндексировал — и он играет в наушниках.</p>

<p>Тонкость, которую стоит знать: у SoulSync два пути добавления. Основной, через поиск-и-подтверждение в интерфейсе, приносит трек с полными тегами, обложкой и текстом. Есть и «сырой» путь (скачать по прямой ссылке) — он кладёт файл без обогащения, плоско. Спасает то, что <strong>Navidrome браузит по тегам, а не по папкам</strong>: даже плоский-но-тегированный файл виден в библиотеке правильно, а безтеговые дочищают repair-джобы поиском по названию (джобы в soulsync многое могут).</p>

<h4 id="источники-под-капотом-youtube-и-торрентная-связка">Источники под капотом: YouTube и торрентная связка</h4>

<p><strong>YouTube</strong> — источник «по умолчанию», настройки не требует вовсе. SoulSync ищет прямо по YouTube, качает лучшую аудиодорожку (на выходе <code class="language-plaintext highlighter-rouge">.opus</code>, обычно ~130–140 kbps) и весь этот трафик гонит через HTTP-прокси gluetun — в compose выше это уже задано переменными <code class="language-plaintext highlighter-rouge">HTTP_PROXY</code>/<code class="language-plaintext highlighter-rouge">HTTPS_PROXY</code>, со своего IP SoulSync наружу не ходит. Дальше обычный конвейер: теги и обложка из метадата-источника, раскладка в <code class="language-plaintext highlighter-rouge">/music</code>. Полезная деталь — встроенная проверка целостности: длительность скачанного сверяется с ожидаемой (допуск порядка 15 секунд), и «не то» отбрасывается. Это спасает от классической подставы YouTube-поиска, когда вместо студийной версии прилетает лайв, ремикс или десятиминутный «full album» — я на этом допуске живьём видел, как отсеялся файл с дрейфом в 31 секунду: пришла другая редакция трека, и система её честно не взяла.</p>

<p>По качеству без иллюзий: opus с YouTube — это пол, а не потолок. Тут у SoulSync два поведения, и их стоит различать. Простое «скачай сейчас» идёт по цепочке приоритетов источников — это настройка, у меня <code class="language-plaintext highlighter-rouge">youtube → soulseek → torrent</code> (YouTube первым: отвечает быстрее всех, для «услышал — добавил» это то, что нужно). А вот автодобор — wishlist, апгрейд качества — собирает кандидатов со всех включённых источников разом и берёт лучшего по качеству: нашёлся FLAC на Soulseek или торрентах — возьмёт его, не нашёлся — YouTube закроет дырку хоть чем-то. Поэтому Soulseek стоит держать включённым, даже если кажется, что «всё равно всё едет с ютуба».</p>

<p><strong>Торренты</strong> — это профиль <code class="language-plaintext highlighter-rouge">torrents</code> из compose и связка трёх ролей: Prowlarr — «адресная книга» трекеров, qBittorrent — качалка, SoulSync — заказчик. Первая пара живёт в сетевом namespace gluetun (<code class="language-plaintext highlighter-rouge">network_mode: service:gluetun</code>), и отсюда неочевидное следствие: у этих контейнеров нет собственного сетевого имени, их веб-морды и API видны <strong>по имени VPN-шлюза</strong>. То есть в интеграциях указывается не <code class="language-plaintext highlighter-rouge">prowlarr:9696</code>, а <code class="language-plaintext highlighter-rouge">http://gluetun:9696</code> и <code class="language-plaintext highlighter-rouge">http://gluetun:8080</code> — это второе после docker-DNS место (грабля №1), где namespace ломает интуицию, закладывайте.</p>

<p>Настройка руками (или инструктажем ИИ — все три пункта делаются через их API):</p>

<ol>
  <li>
    <p><strong>Prowlarr</strong> (<code class="language-plaintext highlighter-rouge">:9696</code>). Добавляем индексаторы: открытые трекеры заводятся логином/паролем — Prowlarr сам логинится и держит сессию; полузакрытые — пасскеем из профиля на трекере. Тут же берём Settings → General → API Key, он понадобится SoulSync.</p>
  </li>
  <li>
    <p><strong>qBittorrent</strong> (<code class="language-plaintext highlighter-rouge">:8080</code>). Здесь ровно одно действие: задать постоянный пароль (грабля №4: временный слетает на рестарте — и интеграция молча отваливается). Каталог загрузок в самом qBit настраивать не нужно — SoulSync передаёт свой <code class="language-plaintext highlighter-rouge">save_path</code> с каждой раздачей; важно лишь, чтобы у обоих контейнеров был примонтирован один и тот же <code class="language-plaintext highlighter-rouge">./data</code> (в compose выше так и есть).</p>
  </li>
  <li>
    <p><strong>SoulSync</strong> → Settings: адрес Prowlarr + его API key, адрес qBittorrent + логин/пароль (оба адреса — через <code class="language-plaintext highlighter-rouge">gluetun:</code>, см. выше), путь загрузок <code class="language-plaintext highlighter-rouge">/data/torrents</code> и категория (у меня <code class="language-plaintext highlighter-rouge">soulsync</code> — по ней закачки SoulSync отличимы от всего остального в qBit). Тест-кнопки должны позеленеть.</p>
  </li>
</ol>

<p>Как идёт закачка целиком: SoulSync спрашивает Prowlarr → тот веером опрашивает все заведённые трекеры и возвращает раздачи с сидами → SoulSync выбирает подходящую и отдаёт её qBittorrent с категорией <code class="language-plaintext highlighter-rouge">soulsync</code> и путём <code class="language-plaintext highlighter-rouge">/data/torrents</code> → qBit качает через туннель → SoulSync видит завершение, вынимает из раздачи нужные файлы, тегирует и раскладывает в <code class="language-plaintext highlighter-rouge">/music</code>. Раздачу, которая не двигается (нулевые сиды), SoulSync бросает по stall-таймауту — у меня 10 минут, — так что мёртвые торренты не висят в очереди вечно. Специфика музыкальных торрентов, о которую спотыкаешься сразу: раздача — почти всегда альбом или дискография, а не отдельный трек. Для «услышал — добавил» этот канал избыточен (там отрабатывают Soulseek и YouTube), зато для FLAC-альбомов и редкого русского каталога он основной — тот самый добор «мёртвых» из главы про миграцию ехал именно здесь.</p>

<p>Проверить, что цепочка жива, можно одним запросом к Prowlarr, не тыкая интерфейсы (3000 — категория «музыка»):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s2">"X-Api-Key: </span><span class="nv">$PROWLARR_KEY</span><span class="s2">"</span> <span class="se">\</span>
  <span class="s1">'http://localhost:9696/api/v1/search?query=&lt;артист&gt;&amp;categories=3000'</span>
<span class="c"># → JSON с релизами и числом сидов. Релизы пришли — трекеры отвечают, дальше дело qBit.</span>
</code></pre></div></div>

<h4 id="flac-апгрейд-на-месте">FLAC-апгрейд на месте</h4>

<p><em>Quality Upgrade</em> — это движок повышения качества. Раз в неделю он читает каждый файл, сравнивает с профилем (cutoff = FLAC), для трека ниже профиля ищет лучшую версию и показывает находку. Подтверждаете — трек качается из общего пула источников, и включённый <em>replace lower quality</em> заменяет старый файл по тому же пути; Navidrome пересканирует, дублей нет.</p>

<p>Важно, почему это <strong>ручное подтверждение, а не автомат</strong>: AcoustID и матчинг по названию регулярно путают каверы, лайв и ремиксы. Живой пример из практики — «Die Antwoord — Baby’s on Fire» опознаётся по звуку как «Goat». Автозамена «на похожее» тихо угробит библиотеку неправильными версиями. Поэтому машина ищет — человек подтверждает. Тот же принцип у всех джобов, которые могут <em>подменить</em> файл: они по умолчанию только показывают находки, применяете вы.</p>

<p>Но и тут я просто попросил ИИ через API все самому разгрести, а то там поначалу были какие-то тысячи находок. Сам я пару песен искал, нашел, которые нужны были и скачал, а все разгребание это puthon скрипты, которые ИИ себе написал и сам все сделал. Тратить на такую ерунду жизнь смысла нет больше.</p>

<h4 id="sonic-рекомендации-на-телефоне">Sonic-рекомендации на телефоне</h4>

<p>AudioMuse анализирует <strong>звук</strong> (Essentia + CLAP-эмбеддинги), а не теги, — поэтому находит похожее даже для обскура, которого нет ни в одной метадата-базе. Плагин <code class="language-plaintext highlighter-rouge">audiomuseai.ndp</code> добавляет Navidrome стандартное OpenSubsonic-расширение sonic-похожести, и оно уезжает на телефон.</p>

<p>В Symfonium это оживает как <strong>Radio Mix</strong> «похожее по звуку» — в настройках клиента включается опция использовать серверную похожесть для радио (иначе радио крутит по артисту, а не по звуку). А вот «умные потоки» Smart Flow (режимы вроде Echo Match) для Subsonic-сервера требуют более свежей версии Symfonium — там завезли поддержку нужного расширения; на более старой они серые. Сервер отдаёт всё — упирается в клиент. Ставьте бэту 15.0 и будет работать.</p>

<p>Второй слой рекомендаций — Explo с ListenBrainz — работает по другой оси: не «похожее по звуку из того, что есть», а «чего у тебя нет» по коллаборативному вкусу. Но я пока проверил, что прослушанное правда уезжает в ListenBraiz, рекомендации оно вроде как генерит раз в неделю, проверю еще.</p>

<h4 id="удаление-трека-с-телефона">Удаление трека с телефона</h4>

<p>Symfonium не умеет слать серверу команду «удали». Обходим просто: заводим на сервере плейлист «Удалить», а на хосте — таймер, который раз в 10 минут переносит помеченные треки в корзину (обратимо, с манифестом). Кинул трек в плейлист с телефона — через несколько минут он уехал из библиотеки, но не пропал безвозвратно. Скрипт тоже написал ИИ, я проверил, что работает.</p>

<h3 id="миграция-и-добор-мёртвых-треков">Миграция и добор «мёртвых» треков</h3>

<p>Отдельный сюжет — как перетащить коллекцию из YTM и вернуть удалённое. Из моей миграции осталось <strong>487 «мёртвых» треков</strong> — тех, что Google уже удалил (unavailable/copyright), преимущественно русское и нишевое. Первое, что нужно сделать, — зафиксировать их намертво списком (videoId, артист, название), пока не потерялись окончательно; этот список потом не трогаем.</p>

<p>Soulseek по такому нишевому вывозит процентов двадцать, поэтому основным каналом добора стали торренты — открытые и полу-закрытые трекеры через тот же Prowlarr+qBit под VPN. Тонкость русских трекеров: они индексят дискографиями, а не треками, поэтому искать надо по <em>артисту</em>, тянуть архив, вынимать из него нужные треки по совпадению имени файла и удалять балласт — диск не резиновый.</p>

<p>Часть «мёртвых» была голыми <code class="language-plaintext highlighter-rouge">videoId</code> без названия. Их можно опознать через публичный YouTube oEmbed — он отдаёт автора и заголовок ролика без единой куки, по одному videoId. То, чего не нашлось на торрентах, добирается YouTube-альтернативами через SoulSync. В сумме у меня вернулось около 372 из 487.</p>

<p>Дальше чистка. Скачанное с YouTube приходит без тегов, и <code class="language-plaintext highlighter-rouge">[Unknown Artist]</code> быстро расплодился. Чинить это надо <strong>по звуку</strong>: AcoustID-фингерпринт опознаёт запись по фактическому содержимому файла, а не по кривому заголовку вида «ARTIST - Title (Official Video) [4K]» — матчинг по такому заголовку ушёл бы не туда. Плюс дедуп по фингерпринту (один трек в разных качествах → оставить лучшее). Честный потолок: то, чего нет ни во FLAC, ни в альтернативной заливке нигде, останется в opus или потеряется — самохостинг не печатает записи, которых не существует.</p>

<p>Главное конечно, а где все это взять? Плейлисты то где? Это в <a href="https://takeout.google.com/">takeout google</a>. Я там достал не только плейлисты, но и всю свою активность начиная с 2009 года, по которой восстановил где и чем я занимался вплоть до дней, включая геолокацию с точностью до города, но это другая история. По активности можно достать и что я слушал, потому, что в плейлистах Гугла останется только то, что он не удалил, а в активности есть все. Я просто попросил ИИ провести анализ и он нарыл ~600 треков из плейлистов и ~2500 треков по косвенным данным активности на “сайте гугла”, а на деле YTM YT.</p>

<h3 id="плейлисты-из-google-takeout--по-шагам">Плейлисты из Google Takeout — по шагам</h3>

<p><a href="https://takeout.google.com/">Takeout</a> — это исходник, но из коробки он даёт не плейлисты, а сырьё. Покажу конкретно, как превратить его в рабочие плейлисты в своём стеке, на тех же ручках, что и всё остальное. Сам я это, понятно, руками не собирал: скинул ИИ папку выгрузки и задачу, он написал пачку python-скриптов и прогнал их через API SoulSync и Navidrome прямо в контейнере. Ниже — механика, чтобы повторить (или чтобы отдать этот раздел своему Клоду и не объяснять на пальцах).</p>

<p><strong>Что внутри Takeout.</strong> Заказываете выгрузку «YouTube and YouTube Music». Плейлисты лежат в <code class="language-plaintext highlighter-rouge">Takeout/YouTube and YouTube Music/playlists/</code> — по одному CSV на плейлист (лайки — отдельным файлом). Нюанс, который экономит полдня недоумения: в CSV <strong>только</strong> <strong><code class="language-plaintext highlighter-rouge">videoId</code></strong> <strong>и метка времени, без названий</strong>. Гугл отдаёт голые идентификаторы, названия достаём сами. И то же, что уже сказано выше про мёртвых: в плейлистах Takeout остаётся лишь то, что Гугл ещё не удалил; полную картину «что я вообще слушал» даёт <code class="language-plaintext highlighter-rouge">Takeout/My Activity/YouTube/</code> (там сразу с заголовками), но это шумный источник — туда падает вообще всё.</p>

<p><strong>Шаг 1.</strong> <strong><code class="language-plaintext highlighter-rouge">videoId</code></strong> <strong>→ артист и название.</strong> По голому id тянем метаданные публичным YouTube oEmbed — без единой куки, по одному id:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="s1">'https://www.youtube.com/oembed?url=https://youtu.be/&lt;videoId&gt;&amp;format=json'</span>
<span class="c"># → {"title":"...","author_name":"Аквариум - Topic", ...}</span>
</code></pre></div></div>

<p>Канал вида <code class="language-plaintext highlighter-rouge">«Артист - Topic»</code> — официальная музыкальная заливка, <code class="language-plaintext highlighter-rouge">author_name</code> из него даёт чистого артиста; для обычных каналов заголовок парсим по «Артист — Название». oEmbed вернул 404 — трек Гугл удалил, это «мёртвый» на добор с торрентов (см. выше).</p>

<p><strong>Шаг 2. Разделяем: что уже в библиотеке, что добирать.</strong> Для каждой пары (артист, название) спрашиваем Navidrome субсоник-поиском, есть ли трек. Токен субсоника — <code class="language-plaintext highlighter-rouge">md5(пароль + соль)</code>, считаем его <strong>локально</strong>, наружу уходит только хеш, не пароль:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s1">'Host: nvd.music'</span> <span class="se">\</span>
  <span class="s1">'http://localhost/rest/search3?query=Аквариум+Город+золотой&amp;u=&lt;user&gt;&amp;t=&lt;md5&gt;&amp;s=&lt;salt&gt;&amp;v=1.16.1&amp;c=cc&amp;f=json'</span>
<span class="c"># song[].id в выдаче → трек в библиотеке; пусто → в добор</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">song.id</code> из выдачи — это и есть идентификатор для плейлиста.</p>

<p><strong>Шаг 3. Недостающее — в добор через SoulSync.</strong> Прогоняем через тот же движок, что наполняет всю библиотеку: сперва резолвим кандидата на Deezer (нужны точные длительность и альбом — страховка от «не той версии»), кладём в wishlist полной карточкой, запускаем обработку. SoulSync качает в лучшем качестве и раскладывает в <code class="language-plaintext highlighter-rouge">/music</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># найти кандидата на Deezer (ручка SoulSync, ключ не нужен)</span>
curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s1">'Host: ss.music'</span> <span class="nt">--data-urlencode</span> <span class="s1">'query=Аквариум Город золотой'</span> <span class="se">\</span>
  <span class="nt">-G</span> <span class="s1">'http://localhost/api/deezer/search_tracks'</span>
<span class="c"># → tracks[].id / name / artists / duration_ms</span>

<span class="c"># положить в wishlist полной карточкой (id, имя, артист, альбом, длительность)</span>
<span class="c"># $SS_KEY — разовый ключ SoulSync API из его настроек</span>
curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s1">'Host: ss.music'</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$SS_KEY</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s1">'Content-Type: application/json'</span> <span class="nt">-X</span> POST <span class="s1">'http://localhost/api/v1/wishlist'</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{"track_data":{"id":"deezer_123","name":"Город золотой","artists":[{"name":"Аквариум"}],"album":{"name":"…"},"duration_ms":222000},"source_type":"manual"}'</span>

<span class="c"># запустить обработку (авто-обработка wishlist и так висит по расписанию, но можно пнуть)</span>
curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s1">'Host: ss.music'</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$SS_KEY</span><span class="s2">"</span> <span class="nt">-X</span> POST <span class="se">\</span>
  <span class="s1">'http://localhost/api/v1/wishlist/process'</span>
</code></pre></div></div>

<p>Место, где легко налажать на пакетном доборе, — <strong>сверка длительности</strong>. Deezer по «артист + название» охотно отдаёт ремикс, лайв или кавер той же длины плюс-минус пара минут; добавишь вслепую — в плейлист приедет не та версия. Берём кандидата, только если имя совпало И длительность в пределах ±5–6 секунд, остальное — на ручную сверку. Тот же принцип «машина ищет — человек подтверждает», что и у апгрейда качества.</p>

<p><strong>Шаг 4. Собираем плейлист в Navidrome.</strong> Когда добор осел и Navidrome пересканировал <code class="language-plaintext highlighter-rouge">/music</code> (новым трекам присвоены <code class="language-plaintext highlighter-rouge">id</code>), собираем плейлист субсоник-ручкой <code class="language-plaintext highlighter-rouge">createPlaylist</code> — имя и список <code class="language-plaintext highlighter-rouge">songId</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="nt">-H</span> <span class="s1">'Host: nvd.music'</span> <span class="se">\</span>
  <span class="s1">'http://localhost/rest/createPlaylist?name=Мой+перенос&amp;songId=&lt;id1&gt;&amp;songId=&lt;id2&gt;&amp;u=&lt;user&gt;&amp;t=&lt;md5&gt;&amp;s=&lt;salt&gt;&amp;v=1.16.1&amp;c=cc&amp;f=json'</span>
</code></pre></div></div>

<p>Грабля напоследок: <code class="language-plaintext highlighter-rouge">songId</code>, которых нет в текущем индексе Navidrome, ручка <strong>молча пропускает</strong>. Если <code class="language-plaintext highlighter-rouge">songCount</code> в ответе меньше, чем вы слали, — часть треков ещё не доехала из добора или не нашлась вовсе; дособрать позже той же <code class="language-plaintext highlighter-rouge">updatePlaylist</code>.</p>

<p><strong>Как это в жизни.</strong> Я отдал ИИ папку <code class="language-plaintext highlighter-rouge">playlists/</code> и сказал «собери в Navidrome, чего нет — в добор». Он распарсил CSV, прогнал id через oEmbed, разделил «есть/нет», недостающее с проверкой длительности покидал в wishlist, а из имеющегося собрал плейлист; мёртвое (oEmbed 404) — отдельным списком на торренты. Всё теми же ручками, что выше. Повторить с другим Клодом — дать ему этот раздел и путь к папке Takeout, дальше он допишет скрипты сам.</p>

<h3 id="грабли">Грабли</h3>

<p>Места, где я потратил время, чтобы вы не потратили.</p>

<ol>
  <li>
    <p><strong>Сетевая ловушка namespace.</strong> Контейнер за <code class="language-plaintext highlighter-rouge">network_mode: service:gluetun</code> теряет docker-DNS и не видит соседей по имени. Поэтому в туннель сажаем только качалки, а SoulSync держим в обычной сети с выходом через прокси VPN. В compose выше это уже разведено.</p>
  </li>
  <li>
    <p><strong>root-owned каталоги и тихо дохнущие сервисы.</strong> Всё крутится под uid 1000; если какой-то каталог остался root-owned от прежнего запуска, systemd-таймеры под обычным пользователем <strong>молча падают</strong> — юнит зелёный, работа не делается. Если «настроено, но не срабатывает» — первым делом проверяйте владельца каталогов, а не логику.</p>
  </li>
  <li>
    <p><strong>AcoustID путает каверы.</strong> Поэтому все операции, которые могут подменить файл (апгрейд качества, ретег по звуку), — на ручном подтверждении. Не автоматизируйте, каким бы заманчивым это ни было. Но я автоматизировал, это всего лишь музыка :)</p>
  </li>
  <li>
    <p><strong>Пароль qBittorrent слетает на рестарте</strong> (образ генерит временный при старте) — интеграция с SoulSync отваливается. Лечится тем, что после первого входа задаёте постоянный пароль в веб-морде qBit, тогда хэш ложится в конфиг и переживает рестарт. Делал ИИ.</p>
  </li>
  <li>
    <p><strong>Killswitch проверяйте IP изнутри namespace</strong>, а не с хоста — иначе увидите egress хоста и сделаете неверный вывод об утечке.</p>
  </li>
  <li>
    <p><strong>Пин версии SoulSync.</strong> Проект очень активный, релизы частые; держите конкретный проверенный тег, а не <code class="language-plaintext highlighter-rouge">latest</code>, — ломающий апдейт прилетит ровно тогда, когда раз в неделю зайдёте что-то скачать.</p>
  </li>
</ol>

<h3 id="что-в-итоге">Что в итоге</h3>

<ul>
  <li>
    <p><strong>Своё ядро.</strong> Navidrome отдаёт мою коллекцию по OpenSubsonic, клиент — Symfonium через mTLS. Стек переживает ребут и не требует няньки в обычный день.</p>
  </li>
  <li>
    <p><strong>Добавление с телефона.</strong> SoulSync-PWA: услышал → нашёл → скачал в лучшем качестве → легло в библиотеку с тегами и обложкой.</p>
  </li>
  <li>
    <p><strong>Приватность проверена, а не заявлена.</strong> Внешнее приобретение — через VPN; Soulseek-P2P в netns с fail-closed killswitch; домашний IP не светится, наружу только <code class="language-plaintext highlighter-rouge">:443</code> за mTLS.</p>
  </li>
  <li>
    <p><strong>Коллекция наполнена и курируется.</strong> Теги причёсаны, обслуживание — на repair-джобах, а не ручном труде.</p>
  </li>
  <li>
    <p><strong>Рекомендации замкнуты.</strong> Explo/ListenBrainz даёт «чего нет», AudioMuse — «похожее по звуку»; sonic доехал до телефона, плейлисты пересобираются каждую ночь сами.</p>
  </li>
</ul>

<p>И всё это <strong>zero-sub</strong> — ни одной активной подписки. Если бы они не хозяйничали в моих плейлистах я так бы и платил, но теперь с ИИ, он настроил все это за три вечера пока я его ругал, что все не так. Самое сложное было подобрать хорошо притертые сервисы и удобные что бы на телефоне было два приложения, одно для поиска (к сожалению PWA лучшее, что найдено) и одно играть. При этом ИИ сам лазит в SoulSync и другие сервисы, я просто в чате пишу “подними качество” или что там у нас по статусу джобов, лазить в PWA SoulSync или еще куда мне не хочется, а в Symfonium на телефоне просто есть огромная коллекция музыки с рекомендациями и просто работает. Как заработало, я так на ковыряниеяи и забил. Symfonium, кстити, не забудьте включить кэширование, что бы задержек не было, оно там выключено по умолчанию. Все вместе даже умные плейлисты строит по спектральному анализу и прочее подобное, все работает и притерто.</p>

<p>P.S. Всем хорошей музыки.</p>]]></content><author><name></name></author><category term="streaming" /><category term="music" /><category term="self-hosted" /><category term="opensourse" /><summary type="html"><![CDATA[В прошлой статье я писал как поднять себе LLM для экспериментов, но сейчас полез чистить «Понравившееся», плейлист на YouTube Music — а часть треков оттуда просто исчезла: где-то удалили клип, где-то канал в бане, где-то правообладатель передумал. Плейлист, который я собирал годами, тихо усыхал, и меня не спросили. Причем у меня есть подписка. Ну и обойдусь, а как, написано в статье.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/proxmox-music-streaming/cover.png" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/proxmox-music-streaming/cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Свой AI-агент из почты, systemd и LLM</title><link href="https://3draven.github.io/blog/2026/05/25/ai-agent-mail-systemd-llm.html" rel="alternate" type="text/html" title="Свой AI-агент из почты, systemd и LLM" /><published>2026-05-25T17:56:26+00:00</published><updated>2026-05-25T17:56:26+00:00</updated><id>https://3draven.github.io/blog/2026/05/25/ai-agent-mail-systemd-llm</id><content type="html" xml:base="https://3draven.github.io/blog/2026/05/25/ai-agent-mail-systemd-llm.html"><![CDATA[<p>В прошлых статьях я строил домашнее облако на Proxmox. Теперь внутри него живёт кое-что поинтереснее — полностью автономный AI-агент, которого я могу пнуть письмом из обычного почтового клиента или сообщением в Telegram, и он ответит, подумав. Причём подумав по-настоящему: с многошаговым рассуждением, долговременной памятью и возможностью выполнять команды. Зовут его Threlium, и устроен он чертовски необычно и просто, например может модифицировать сам себя. В целом это подход построения агентов любой сложности на основе linux утилит.</p>

<p>Зачем, почему и что получилось — расскажу ниже.</p>

<hr />

<h3 id="зачем-свой-агент">Зачем свой агент</h3>

<p>Все облачные LLM-ассистенты — чужие. Данные уходят на сервера провайдера, контекст ограничен одним окном чата, настоящей долговременной памяти можно сказать нет, есть чат-сессия. При этом умные модели уже бегают локально на средненьком GPU: Qwen, Llama, Mistral. Возникает вопрос: а нельзя ли собрать агента, который будет жить на моём сервере, работать с моими данными и при этом иметь настоящий цикл рассуждений — не просто «спросил → ответил», а полноценный конечный автомат с ветвлениями, памятью и инструментами Причем хочется что бы кода в нем было, почти не было по сравнению с аналогами и он мог править сам себя или легко отлаживаться.</p>

<p>Оказывается, можно. Причём из довольно простых Unix-кирпичиков.</p>

<hr />

<h3 id="что-дает-ниже-описанный-поход">Что дает ниже описанный поход</h3>

<ol>
  <li>
    <p>Мало кода, если выкинуть клеекод и типизацию (модуль types), то останется примерно 6к строк пайтон скриптов. У аналогов примерно то же быстро становится сотнями тысяч строк.</p>
  </li>
  <li>
    <p>Наблюдаемость, вся работа агента сразу видна и никаких инфраструктур для отслеживания его работы просто не нужно.</p>
  </li>
  <li>
    <p>Простота конструкции, это просто набор конфигов и немного скриптов. В качестве инсталлятора ansible playbook.</p>
  </li>
  <li>
    <p>Минимальное потребление ресурсов, агент влезет на самую дешевую VPS.</p>
  </li>
  <li>
    <p>Агент может править себя.</p>
  </li>
  <li>
    <p>Вся эта конструкция не требует инфраструктуры вовсе, никаких сложных миллионов контейнеров, мониторинг вытекает из ее свойств, отлаживать проблемы этой штуки очень легко. Достаточно дать ssh на сервер агенту и показать папку где отлаживаемый агент развернут, наблюдаемость у системы просто отличная.</p>
  </li>
</ol>

<hr />

<h3 id="философия-событие--письмо">Философия: событие = письмо</h3>

<p>Самое неожиданное архитектурное решение Threlium — в его основе лежит электронная почта. Не как транспорт «для связи с пользователем», а как фундаментальная модель данных.</p>

<p>Любое событие в системе — это RFC 5322 сообщение. Буквально MIME-файл с заголовками и телом. Переход между состояниями конечного автомата — доставка нового письма в другой Maildir. Хранилище — тоже Maildir (формат <code class="language-plaintext highlighter-rouge">tmp/</code>, <code class="language-plaintext highlighter-rouge">new/</code>, <code class="language-plaintext highlighter-rouge">cur/</code>, атомарная запись через <code class="language-plaintext highlighter-rouge">rename(2)</code>). Индекс — notmuch поверх всех этих Maildir’ов. Это просто обыкновенная переписка между почтовыми ящиками, которая может моделировать как конечный автомат, так и модель акторов если хочется.</p>

<p>Звучит безумно? Возможно. Но вот что это даёт:</p>

<ul>
  <li>
    <p>Каждое событие — файл на диске. Можно открыть mutt’ом, grep’нуть, написать скрипт.</p>
  </li>
  <li>
    <p>Никакого отдельного брокера сообщений. Maildir — это и очередь, и canonical event store.</p>
  </li>
  <li>
    <p>Идемпотентность из коробки: файл либо в <code class="language-plaintext highlighter-rouge">new/</code>, либо в <code class="language-plaintext highlighter-rouge">cur/</code>, третьего не дано.</p>
  </li>
  <li>
    <p>Отказоустойчивость: упал процесс — файл остался в <code class="language-plaintext highlighter-rouge">new/</code>, следующий запуск подберёт.</p>
  </li>
  <li>
    <p>Полная история навсегда: после обработки файл переезжает в <code class="language-plaintext highlighter-rouge">cur/&lt;id&gt;:2,S</code>, не удаляется.</p>
  </li>
</ul>

<p>Один <code class="language-plaintext highlighter-rouge">notmuch search '*'</code> — и вы видите абсолютно все события системы за всё время. Это логический «архив». Простая почта позволяет просто открыть веб-интерфейс и посмотреть все “размышления” агента.</p>

<hr />

<h3 id="конечный-автомат-на-maildirах">Конечный автомат на Maildir’ах</h3>

<p>Threlium — IRT-tree FSM. Расшифрую: конечный автомат, у которого состояния — очереди Maildir, а граф переходов определяется In-Reply-To цепочками писем. Глобального координатора нет. Состояние фрейма (бюджет шагов, права) живёт прямо в заголовках письма — <code class="language-plaintext highlighter-rouge">X-Threlium-Hop-Budget</code>, <code class="language-plaintext highlighter-rouge">X-Threlium-Capabilities</code>.</p>

<h4 id="стадии-fsm">Стадии FSM</h4>

<p>Пока что их немного, я просто закончил базу и так как буду развивать ее далее, решил не тянуть и описать ее.</p>

<ul>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">ingress</code></strong> — единая точка входа. Сюда приходят все сообщения от всех каналов.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">enrich</code></strong> — обогащение контекстом. Здесь подключается LightRAG (граф знаний) и хронология треда.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">reasoning</code></strong> — собственно рассуждение. LLM получает промпт с контекстом и отвечает через tool calls.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">egress_router</code></strong> — маршрутизатор выхода. По depth IRT-цепочки решает: ответ пользователю или возврат в субагент.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">egress_email</code></strong>, <strong><code class="language-plaintext highlighter-rouge">egress_telegram</code></strong>, <strong><code class="language-plaintext highlighter-rouge">egress_matrix</code></strong> — терминальные стадии доставки наружу.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">cli_intent</code></strong> — политика: можно ли выполнять команду?</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">cli_exec</code></strong> — песочница для исполнения shell-команд.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">thread_memory</code></strong>, <strong><code class="language-plaintext highlighter-rouge">global_memory</code></strong> — FSM-состояния для работы с памятью.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">archive</code></strong> — финальная запись об отправке.</p>
  </li>
</ul>

<h4 id="контракт-стадии">Контракт стадии</h4>

<p>Каждая стадия — Python-модуль <code class="language-plaintext highlighter-rouge">threlium.states.&lt;stage&gt;</code> с одной функцией:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">main</span><span class="p">(</span><span class="n">msg</span><span class="p">:</span> <span class="n">EmailMessage</span><span class="p">,</span> <span class="n">stage</span><span class="p">:</span> <span class="n">FsmStage</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">config</span><span class="p">:</span> <span class="n">Config</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">EmailMessage</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
</code></pre></div></div>

<p>Принимает письмо — возвращает новое письмо (переход дальше) или <code class="language-plaintext highlighter-rouge">None</code> (терминальная стадия). Всё остальное — транспорт и оркестрация — за пределами стадии. Стадия не трогает файлы, не вызывает systemctl, не знает про fdm. Чистая функция над stdlib <code class="language-plaintext highlighter-rouge">email.message.EmailMessage</code>. По возможности конечно.</p>

<h4 id="граф-переходов-fsm">Граф переходов FSM</h4>

<p>PlantUML-исходник: Граф переходов FSM</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
left to right direction

package "Вход" {
  actor "Пользователь" as User
  rectangle "bridge-email" as EB
  rectangle "bridge-telegram" as TB
  rectangle "bridge-matrix" as MB
  User --&gt; EB : Email
  User --&gt; TB : Telegram
  User --&gt; MB : Matrix
}

rectangle "fdm → notmuch insert" as FDM
EB --&gt; FDM : run_fdm
TB --&gt; FDM : run_fdm
MB --&gt; FDM : run_fdm

rectangle "ingress" as ING
rectangle "enrich" as ENR
rectangle "reasoning" as REA
FDM --&gt; ING
ING --&gt; ENR
ENR --&gt; REA

rectangle "egress_router" as EGR
rectangle "cli_intent" as CLI
rectangle "thread_memory" as TM
rectangle "global_memory" as GM
rectangle "subagent_intent" as SI
rectangle "reflect" as REF

REA --&gt; EGR : "tool: egress_router"
REA --&gt; CLI : "tool: cli_intent"
REA --&gt; TM : "tool: thread_memory"
REA --&gt; GM : "tool: global_memory"
REA --&gt; SI : "tool: subagent_intent"
REA --&gt; REF : "tool: reflect"

rectangle "cli_exec" as EXEC
rectangle "cli_hitl_out" as HITL
CLI --&gt; EXEC : allow
CLI --&gt; ING : deny
CLI --&gt; HITL : HITL
HITL --&gt; EGR
EXEC --&gt; ING

TM --&gt; ING
GM --&gt; ING
REF --&gt; ING
SI --&gt; ING

rectangle "egress_email" as EE
rectangle "egress_telegram" as ET
rectangle "egress_matrix" as EM
rectangle "subagent_end" as SE
rectangle "archive" as ARC

EGR --&gt; EE : "depth == 0"
EGR --&gt; ET : "depth == 0"
EGR --&gt; EM : "depth == 0"
EGR --&gt; SE : "depth &gt; 0"
SE --&gt; ING

EE --&gt; ARC
ET --&gt; ARC
EM --&gt; ARC

actor "Пользователь" as UserOut
EE --&gt; UserOut
ET --&gt; UserOut
EM --&gt; UserOut
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/01.png" alt="Граф переходов FSM" /></p>

<p>Граф переходов FSM</p>

<hr />

<h3 id="маршрутизация-fdm--notmuch-insert">Маршрутизация: fdm + notmuch insert</h3>

<p>Для доставки писем между стадиями используется <code class="language-plaintext highlighter-rouge">fdm</code> — лёгкий mail delivery agent. Конфиг <code class="language-plaintext highlighter-rouge">~/.fdm.conf</code> (генерируется Ansible из Jinja2-шаблона) содержит правила маршрутизации по заголовку <code class="language-plaintext highlighter-rouge">To:</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>match "To" ... action pipe "notmuch insert --folder=stages/reasoning/Maildir ... &amp;&amp; threlium-dispatch.sh"
</code></pre></div></div>

<p>Ключевое: <code class="language-plaintext highlighter-rouge">notmuch insert</code> — это атомарная операция. Файл записывается в Maildir <strong>и</strong> индексируется в notmuch одной транзакцией. Никакого <code class="language-plaintext highlighter-rouge">notmuch new</code> отдельно не нужно. После успешного insert тут же вызывается dispatch-скрипт, который поднимает воркер для обработки.</p>

<p>Почту давно придумали, не нужно писать ее снова для модели акторов. Нам нет острой необходимости экономить миллисекунды так как агенты работают десяткти минут.</p>

<hr />

<h3 id="оркестрация-systemd-user">Оркестрация: systemd –user</h3>

<p>Никаких Celery, RabbitMQ, Kubernetes. Оркестрация — это <code class="language-plaintext highlighter-rouge">systemd --user</code>. Вот как работает цепочка:</p>

<p>PlantUML-исходник: Оркестрация systemd</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
top to bottom direction

rectangle "fdm → notmuch insert\n--folder=stages/‹stage›/Maildir" as FDM
rectangle "threlium-dispatch.sh\nnotmuch search tag:unread\nAND folder:‹stage›/Maildir\n→ systemctl start --no-block" as DISP
rectangle "threlium-work@‹stage›:‹thread_id›\nType=exec\npython -m threlium.runners.engine_submit %i\n→ JSON в UNIX-сокет" as WORK
rectangle "threlium-engine.service\nДолгоживущий демон\nparse_rfc822 → main() → run_fdm()" as ENGINE
rectangle "nm_settle()\nnew/‹id› → cur/‹id›:2,S\ntags.discard unread + to_maildir_flags" as SETTLE
rectangle "threlium-sweep@‹stage›:‹thread_id›\nRace backstop\nthrelium-dispatch.sh %i\nперепроверка backlog" as SWEEP
rectangle "RAG-loop в engine\nrag.ainsert + tag +lightrag_indexed" as RAG
rectangle "JSON error → submit exit 1\nRestart=on-failure\nбез sweep" as ERR

FDM --&gt; DISP : "&amp;&amp; threlium-dispatch.sh"
DISP --&gt; WORK
WORK --&gt; ENGINE
ENGINE --&gt; SETTLE
SETTLE --&gt; SWEEP : "exit 0 → OnSuccess"
SWEEP ..&gt; DISP : "хвост unread"
SETTLE ..&gt; RAG : "schedule_index_pending"
ENGINE --&gt; ERR : "exception"
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/02.png" alt="Оркестрация systemd" /></p>

<p>Оркестрация systemd</p>

<p>Имя инстанса воркера <code class="language-plaintext highlighter-rouge">threlium-work@enrich:000000000012ab.service</code> — это одновременно и мьютекс. systemd гарантирует: один инстанс с данным именем — один тред. Параллельно обрабатываются разные треды (разные <code class="language-plaintext highlighter-rouge">thread_id</code>), последовательно — письма одного треда (oldest-first FIFO).</p>

<p>Никакого <code class="language-plaintext highlighter-rouge">flock</code>. Никакого пула потоков. Никакого coordinator. Всё бесплатно из systemd: перезапуски при сбоях (<code class="language-plaintext highlighter-rouge">Restart=on-failure</code>), лимиты ресурсов (<code class="language-plaintext highlighter-rouge">threlium-work.slice</code> с <code class="language-plaintext highlighter-rouge">TasksMax</code>, <code class="language-plaintext highlighter-rouge">MemoryMax</code>, <code class="language-plaintext highlighter-rouge">CPUQuota</code>), cgroup-изоляция, логи через <code class="language-plaintext highlighter-rouge">journalctl</code>.</p>

<p>Никакого в сотый раз написанного с нуля с косяками superviser for actors не нужно, KISS…my ass. Причем systemd –user выбран не просто так, это именно пользовательские unit, они живут в папке пользователя и в одном git репозитории все, так что их легко править и коммитить самому агенту если нужно.</p>

<hr />

<h3 id="многоканальный-вход-email-telegram-matrix">Многоканальный вход: Email, Telegram, Matrix</h3>

<p>Threlium принимает сообщения из трёх каналов. Каждый канал — это мост (<code class="language-plaintext highlighter-rouge">threlium-bridge@&lt;chan&gt;.service</code>), который нормализует входящий сигнал в каноническое RFC 5322 письмо:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Канал</td>
      <td>Транспорт</td>
      <td><code class="language-plaintext highlighter-rouge">From:</code></td>
    </tr>
    <tr>
      <td>Email</td>
      <td>IMAP IDLE (imap-tools)</td>
      <td><code class="language-plaintext highlighter-rouge">email@localhost</code></td>
    </tr>
    <tr>
      <td>Telegram</td>
      <td>Long-poll (Bot API)</td>
      <td><code class="language-plaintext highlighter-rouge">telegram@localhost</code></td>
    </tr>
    <tr>
      <td>Matrix</td>
      <td>Sync-loop (matrix-nio)</td>
      <td><code class="language-plaintext highlighter-rouge">matrix@localhost</code></td>
    </tr>
  </tbody>
</table>

<p>Маршрутная информация канала (<code class="language-plaintext highlighter-rouge">chat_id</code>, <code class="language-plaintext highlighter-rouge">room_id</code>, <code class="language-plaintext highlighter-rouge">update_id</code>, reply targets) кодируется в заголовок <code class="language-plaintext highlighter-rouge">X-Threlium-Route</code> как <code class="language-plaintext highlighter-rouge">base62(JSON)</code>. Это позволяет при ответе вернуть сообщение ровно в тот чат/комнату/ящик, откуда оно пришло.</p>

<p>На выходе <code class="language-plaintext highlighter-rouge">egress_router</code> определяет канал по <code class="language-plaintext highlighter-rouge">X-Threlium-Route</code> и маршрутизирует в <code class="language-plaintext highlighter-rouge">egress_email</code>, <code class="language-plaintext highlighter-rouge">egress_telegram</code> или <code class="language-plaintext highlighter-rouge">egress_matrix</code>. Симметрия: вход и выход устроены одинаково.</p>

<p>PlantUML-исходник: Ingress/Egress мосты</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
left to right direction

package "Ingress-мосты" {
  rectangle "Email\nIMAP IDLE" as E
  rectangle "Telegram\nLong-poll" as T
  rectangle "Matrix\nmatrix-nio sync" as M
}

rectangle "run_fdm\n→ fdm → notmuch insert\nstages/ingress/Maildir" as FDM
rectangle "FSM\ningress → enrich → reasoning → ..." as FSM
rectangle "egress_router\nresolve X-Threlium-Route\n→ channel" as EGR

E --&gt; FDM : "From: email@localhost\nX-Threlium-Route: b62(JSON)"
T --&gt; FDM : "From: telegram@localhost\nX-Threlium-Route: b62(JSON)"
M --&gt; FDM : "From: matrix@localhost\nX-Threlium-Route: b62(JSON)"

FDM --&gt; FSM
FSM --&gt; EGR

package "Egress" {
  rectangle "egress_email\nmsmtp → SMTP" as EE
  rectangle "egress_telegram\nBot API" as ET
  rectangle "egress_matrix\nClient-Server API" as EM
}

EGR --&gt; EE
EGR --&gt; ET
EGR --&gt; EM
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/03.png" alt="Ingress/Egress мосты" /></p>

<p>Ingress/Egress мосты</p>

<p>Чекпоинтов вне union-индекса нет. При рестарте мост восстанавливает курсор из <code class="language-plaintext highlighter-rouge">X-Threlium-Route</code> последнего доставленного письма через notmuch-поиск. Никакой отдельной БД offset’ов. Тот же KISS.</p>

<hr />

<h3 id="llm-tool-calls-как-единственный-механизм">LLM: tool calls как единственный механизм</h3>

<p>Стадия <code class="language-plaintext highlighter-rouge">reasoning</code> — точка контакта с LLM. Используется <code class="language-plaintext highlighter-rouge">litellm</code> (Python SDK для OpenAI-совместимых API, версия 1.83 уже без взломов :) ). Модель получает:</p>

<ol>
  <li>
    <p><strong>System-промпт</strong> из Jinja2-шаблона <code class="language-plaintext highlighter-rouge">reasoning/system.j2</code>.</p>
  </li>
  <li>
    <p><strong>User-промпт</strong> с обогащённым контекстом из <code class="language-plaintext highlighter-rouge">enrich</code> (контекст графа знаний + хронология треда).</p>
  </li>
  <li>
    <p><strong>Список tool_specs</strong> для каждого возможного маршрута: <code class="language-plaintext highlighter-rouge">egress_router</code>, <code class="language-plaintext highlighter-rouge">cli_intent</code>, <code class="language-plaintext highlighter-rouge">thread_memory</code>, <code class="language-plaintext highlighter-rouge">global_memory</code>, <code class="language-plaintext highlighter-rouge">subagent_intent</code>.</p>
  </li>
</ol>

<p>Модель отвечает <strong>tool call’ом</strong>, а не свободным текстом. Это принципиальное решение: LLM — источник намерения, FSM — исполнитель. Парсинга свободного текста для выбора маршрута нет. Если модель вернула текст без tool call — <code class="language-plaintext highlighter-rouge">ReasoningStageError</code>, письмо остаётся в <code class="language-plaintext highlighter-rouge">new/+unread</code>, воркер завершается с <code class="language-plaintext highlighter-rouge">exit 1</code>, systemd делает retry. Позже я сделаю дополнительные стадии FSM для формирования больших ответов, но пока что есть минимум.</p>

<p>Аргументы tool call валидируются <code class="language-plaintext highlighter-rouge">jsonschema</code> (JSON Schema с <code class="language-plaintext highlighter-rouge">additionalProperties: false</code> и <code class="language-plaintext highlighter-rouge">maxLength</code>-лимитами). Прошла валидация — из аргументов собирается новое письмо с <code class="language-plaintext highlighter-rouge">To: &lt;next_stage&gt;@localhost</code> и отправляется через <code class="language-plaintext highlighter-rouge">run_fdm</code> в следующую стадию.</p>

<p>Для разных маршрутов — разные JSON-Schema инструментов, разные шаблоны тела и темы письма. Всё живёт в <code class="language-plaintext highlighter-rouge">prompts/reasoning/&lt;route&gt;/tool_spec.j2</code>, <code class="language-plaintext highlighter-rouge">email_body.j2</code>, <code class="language-plaintext highlighter-rouge">email_subject.j2</code>. Оператор может редактировать промпты без правки Python-кода.</p>

<hr />

<h3 id="трёхслойная-память">Трёхслойная память</h3>

<p>У агента три уровня памяти:</p>

<h4 id="1-локальный-тред">1. Локальный тред</h4>

<p>Хронология текущего диалога собирается из union notmuch index’а. Стадия <code class="language-plaintext highlighter-rouge">enrich</code> проходит по цепочке <code class="language-plaintext highlighter-rouge">In-Reply-To</code> до корня ветки и формирует <code class="language-plaintext highlighter-rouge">unified_messages</code> — полную хронологию.</p>

<h4 id="2-глобальные-факты">2. Глобальные факты</h4>

<p><code class="language-plaintext highlighter-rouge">global_memory</code> и <code class="language-plaintext highlighter-rouge">thread_memory</code> — обычные FSM-состояния. <code class="language-plaintext highlighter-rouge">reasoning</code> может вызвать tool call <code class="language-plaintext highlighter-rouge">thread_memory</code> или <code class="language-plaintext highlighter-rouge">global_memory</code>, записать факт, и он вернётся в <code class="language-plaintext highlighter-rouge">ingress</code> для следующей итерации.</p>

<h4 id="3-граф-знаний-lightrag">3. Граф знаний (LightRAG)</h4>

<p>Embedded LightRAG (<code class="language-plaintext highlighter-rouge">lightrag-hku</code>) работает как single-writer внутри <code class="language-plaintext highlighter-rouge">threlium-engine</code>. После каждого <code class="language-plaintext highlighter-rouge">nm_settle()</code> (когда письмо обработано и переехало в <code class="language-plaintext highlighter-rouge">cur/</code>) запускается <code class="language-plaintext highlighter-rouge">schedule_index_pending</code> — RAG-loop подбирает settled-сообщения, которые ещё не проиндексированы (<code class="language-plaintext highlighter-rouge">NOT tag:unread AND NOT tag:lightrag_indexed</code>), и вставляет их в граф через <code class="language-plaintext highlighter-rouge">rag.ainsert()</code>. Это не просто RAG, а граф связанных знаний.</p>

<p>PlantUML-исходник: LightRAG write/read</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
left to right direction

package "Запись (async, после settle)" {
  rectangle "nm_settle()" as SETTLE
  rectangle "schedule_index_pending" as SCHED
  rectangle "Селектор:\nNOT tag:unread\nAND NOT tag:lightrag_indexed" as SEL
  rectangle "rag.ainsert(batch)" as INS
  rectangle "+lightrag_indexed" as TAG
  SETTLE --&gt; SCHED
  SCHED --&gt; SEL
  SEL --&gt; INS
  INS --&gt; TAG
}

package "Чтение (sync, в FSM)" {
  rectangle "enrich" as ENR
  rectangle "LLM: enrich_query_plan.j2" as PLAN
  rectangle "rag.aquery(...)" as AQ
  rectangle "Payload:\n--- user message ---\n--- lightrag context ---" as PAY
  rectangle "reasoning" as REA
  ENR --&gt; PLAN
  PLAN --&gt; AQ
  AQ --&gt; PAY
  PAY --&gt; REA
}

INS ..&gt; AQ : "общий\nworking_dir/"
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/04.png" alt="LightRAG write/read" /></p>

<p>LightRAG write/read</p>

<p>Стадия <code class="language-plaintext highlighter-rouge">enrich</code> при формировании контекста для <code class="language-plaintext highlighter-rouge">reasoning</code> вызывает <code class="language-plaintext highlighter-rouge">rag.aquery()</code> — семантический запрос к графу. Результат вместе с хронологией треда упаковывается в тело письма для <code class="language-plaintext highlighter-rouge">reasoning</code> через Jinja2-шаблоны. Пока что опять же это простейшее решение несмотря на мощь GraphRAG подхода, нужно дорабатывать подходы к экономии контекста, но когда кода совсем мало это не сложно, даже не шибко умные нейронки вайбкодят при таких объемах проекта легко.</p>

<p>Линейных цепочек для контекста отслеживать не нужно — весь тред со всеми форками индексируется как единая база знаний (глобальные знания агента). Mutex на весь тред от ingress до ответа не нужен.</p>

<hr />

<h3 id="безопасность-решение--политика--исполнение">Безопасность: решение / политика / исполнение</h3>

<p>Слой CLI построен на строгом разделении:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Стадия</td>
      <td>Ответственность</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">reasoning</code></td>
      <td>Формирует намерение через tool call: «хочу выполнить echo hello»</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cli_intent</code></td>
      <td>Политика: allow / deny / ask-human. Команды <strong>не исполняет</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cli_exec</code></td>
      <td>Исполнение разрешённой команды в песочнице <code class="language-plaintext highlighter-rouge">systemd-run --scope</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">cli_intent</code> использует грубый фильтр: запрещённые подстроки (<code class="language-plaintext highlighter-rouge">;</code>, <code class="language-plaintext highlighter-rouge">|</code>, <code class="language-plaintext highlighter-rouge">$(</code>, <code class="language-plaintext highlighter-rouge">&amp;&amp;</code>), белый список базовых команд. Осознанно жёсткий — ложные срабатывания лучше, чем пропущенная инъекция. Если команда не попала ни в allow, ни в deny — уходит на подтверждение к человеку (HITL). Это решение я пока оставил совсем простым так же как и другие, суть в использовании systemd-run в будущем, пока просто заготовка.</p>

<h4 id="hitl-прерывание">HITL-прерывание</h4>

<p>PlantUML-исходник: HITL-прерывание</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
participant "reasoning" as R
participant "cli_intent" as CI
participant "cli_hitl_out" as HO
participant "egress_router" as ER
actor "Пользователь" as U
participant "ingress" as I
participant "cli_resume" as CR
participant "cli_exec" as CE

R -&gt; CI : tool call: rm -rf /tmp/data
CI -&gt; CI : classify: не в allow, не в deny
CI -&gt; HO : HITL: спросить пользователя
HO -&gt; ER : письмо с вопросом
ER -&gt; U : «Разрешить rm -rf /tmp/data?»
U -&gt; I : «Да» (ответ через канал)
I -&gt; I : IRT-обход → From: cli_hitl_out
I -&gt; CR : cli_resume
CR -&gt; CE : cli_exec (разрешено)
CE -&gt; I : observation (результат)
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/05.png" alt="HITL-прерывание" /></p>

<p>HITL-прерывание</p>

<p><code class="language-plaintext highlighter-rouge">cli_exec</code> запускает команду через <code class="language-plaintext highlighter-rouge">systemd-run --scope</code> с лимитами из <code class="language-plaintext highlighter-rouge">X-Threlium-Capabilities</code> текущего фрейма: <code class="language-plaintext highlighter-rouge">MemoryMax</code>, <code class="language-plaintext highlighter-rouge">CPUQuota</code>, <code class="language-plaintext highlighter-rouge">TasksMax</code>, <code class="language-plaintext highlighter-rouge">timeout</code>.</p>

<hr />

<h3 id="субагенты-рекурсия-через-irt-цепочку">Субагенты: рекурсия через IRT-цепочку</h3>

<p>Threlium поддерживает вложенные вызовы агентов (L0 → L1 → L2). Реализация — маркеры <code class="language-plaintext highlighter-rouge">subagent_intent</code> / <code class="language-plaintext highlighter-rouge">subagent_end</code> в IRT-дереве:</p>

<ul>
  <li>
    <p><code class="language-plaintext highlighter-rouge">reasoning</code> на L0 вызывает tool <code class="language-plaintext highlighter-rouge">subagent_intent</code> → маркер в IRT-цепочке с изолированным hop/cap.</p>
  </li>
  <li>
    <p>Субагент на L1 не знает, кто его вызвал. Работает как обычный агент.</p>
  </li>
  <li>
    <p>По завершении <code class="language-plaintext highlighter-rouge">egress_router</code> по depth &gt; 0 маршрутизирует результат не наружу, а в <code class="language-plaintext highlighter-rouge">subagent_end</code>.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">subagent_end</code> находит соответствующий <code class="language-plaintext highlighter-rouge">subagent_intent</code> по IRT, копирует hop/cap родителя и возвращает письмо в <code class="language-plaintext highlighter-rouge">ingress</code>.</p>
  </li>
</ul>

<p>PlantUML-исходник: Субагенты L0/L1</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
top to bottom direction

package "L0 — основной диалог" {
  rectangle "reasoning L0" as R0
  rectangle "subagent_intent\n(маркер + изолированный hop/cap)" as SI
  rectangle "ingress" as ING1
  R0 --&gt; SI : "tool: subagent_intent"
  SI --&gt; ING1
}

package "L1 — субагент" {
  rectangle "enrich" as ENR1
  rectangle "reasoning L1" as R1
  rectangle "egress_router\ndepth=1 &gt; 0" as EGR1
  ING1 --&gt; ENR1
  ENR1 --&gt; R1
  R1 --&gt; EGR1 : "tool: egress_router"
}

rectangle "subagent_end\nIRT-обход → subagent_intent\nкопия hop/cap родителя" as SE
rectangle "ingress L0" as ING0
rectangle "enrich L0" as ENR0
rectangle "reasoning L0\n(с результатом субагента)" as R0_2
rectangle "egress_router\ndepth=0" as EGR0
rectangle "egress_‹chan›\n→ пользователь" as OUT

EGR1 --&gt; SE
SE --&gt; ING0
ING0 --&gt; ENR0
ENR0 --&gt; R0_2
R0_2 --&gt; EGR0 : "tool: egress_router"
EGR0 --&gt; OUT
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/06.png" alt="Субагенты L0/L1" /></p>

<p>Субагенты L0/L1</p>

<p>Глубина определяется линейным обходом IRT-цепочки: каждый <code class="language-plaintext highlighter-rouge">subagent_intent</code> → depth+1, каждый <code class="language-plaintext highlighter-rouge">subagent_end</code> → depth−1. Промежуточного in-memory state нет. Пока что решение довольно линейное и предназначено как и вся идея subagent для изоляции контекста, subagent начинает работать как бы “от запроса пользвоателя”, просто пользователь это другой агент. Позже можно реализовать параллельные агенты это в целом уже придумано, сделаю когда захочется, все можно построить на форках почтового треда и стадии слияния в FSM - это ведь по сути просто “обсуждение в почте”. По сравнению с почтой тут только будет ромб в графе, но это решаемо и совсем просто.</p>

<hr />

<h3 id="идентификаторы-base62-и-msgspec">Идентификаторы: base62 и msgspec</h3>

<p>Внутри системы все <code class="language-plaintext highlighter-rouge">Message-ID</code> канонизированы в форму <code class="language-plaintext highlighter-rouge">&lt;base62(payload)@localhost&gt;</code>:</p>

<ul>
  <li>
    <p><strong>Email:</strong> <code class="language-plaintext highlighter-rouge">payload = msgspec JSON EmailNativeId(v=1, message_id="&lt;оригинальный MID&gt;")</code></p>
  </li>
  <li>
    <p><strong>Telegram/Matrix:</strong> <code class="language-plaintext highlighter-rouge">payload = utf8(composite_inner)</code></p>
  </li>
</ul>

<p>Схема обратима: <code class="language-plaintext highlighter-rouge">base62.decodebytes</code> + <code class="language-plaintext highlighter-rouge">msgspec.json.decode</code> восстанавливают исходный struct. На egress для email — полное восстановление оригинального <code class="language-plaintext highlighter-rouge">Message-ID</code>, <code class="language-plaintext highlighter-rouge">In-Reply-To</code>, <code class="language-plaintext highlighter-rouge">References</code> для корректной цепочки в почтовом клиенте получателя.</p>

<p><code class="language-plaintext highlighter-rouge">base62</code> использует алфавит <code class="language-plaintext highlighter-rouge">[0-9A-Za-z]</code> — строгое подмножество <code class="language-plaintext highlighter-rouge">atext</code> RFC 5322, так что канонический <code class="language-plaintext highlighter-rouge">Message-ID</code> всегда валиден. Двоеточия Matrix, слэши msgid, <code class="language-plaintext highlighter-rouge">$</code> Matrix-v3 — всё безопасно уходит в base62.</p>

<p>Сериализация через <code class="language-plaintext highlighter-rouge">msgspec</code> — детерминистическая (фиксированный порядок полей, без пробелов). Никаких mapping-таблиц и state: всё выводится из самого id через обратное преобразование. Это позволяет превратить в внутреннее почтовое сообщение любое сообщение из внешнего канала. Внутри агент это просто набор папок с eml файлами на диске и все.</p>

<hr />

<h3 id="промпты-jinja2-шаблоны-редактируемые-оператором">Промпты: Jinja2 шаблоны, редактируемые оператором</h3>

<p>Всё, что видит LLM и пользователь, генерируется из Jinja2-шаблонов в <code class="language-plaintext highlighter-rouge">$THRELIUM_HOME/prompts/&lt;stage&gt;/&lt;purpose&gt;.j2</code>. Код вызывает только <code class="language-plaintext highlighter-rouge">render_prompt(name, **vars)</code>. Шаблоны деплоятся Ansible из <code class="language-plaintext highlighter-rouge">roles/threlium/files/prompts/</code>.</p>

<p>Это касается не только «обычных» промптов, но и:</p>

<ul>
  <li>
    <p><strong>Overlay внутренних промптов LightRAG</strong> — 12 файлов, копии <code class="language-plaintext highlighter-rouge">lightrag.prompt.PROMPTS</code> для текущей версии <code class="language-plaintext highlighter-rouge">lightrag-hku</code>.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">addon_params</code></strong> для LightRAG — language, entity_types — JSON из Jinja2.</p>
  </li>
  <li>
    <p><strong>Per-route tool-specs</strong> для reasoning — 6 маршрутов × 3 файла = 18 артефактов.</p>
  </li>
</ul>

<p>Смена поведения агента — правка шаблона и <code class="language-plaintext highlighter-rouge">systemctl --user restart threlium-engine.service</code>. Без коммита в Python. Но можно и скрипты свободно править конечно, это не компилируемый язык и это сознательное решение, так как сам агент можнт это делать, а так как ресурсов он ест мало, то на той же машине можно завести второго и когда один агент доломал себя, второго попросить просто откатить git репозиторий в котором живет почивший. Никаких компиляций сложной отладки и прочего.</p>

<hr />

<h3 id="развёртывание-ansible-push-модель">Развёртывание: Ansible push-модель</h3>

<p>Threlium не клонируется <code class="language-plaintext highlighter-rouge">git clone</code>-ом на целевой хост. Развёртывание — push-модель: <code class="language-plaintext highlighter-rouge">ansible-playbook ansible/playbooks/site.yml</code> с control node заливает всё на target.</p>

<h4 id="что-попадает-на-target">Что попадает на target</h4>

<ul>
  <li>
    <p>Python-пакет <code class="language-plaintext highlighter-rouge">threlium</code> (editable install в единый <code class="language-plaintext highlighter-rouge">.venv</code>).</p>
  </li>
  <li>
    <p>Конфигурации: <code class="language-plaintext highlighter-rouge">threlium.yaml</code>, <code class="language-plaintext highlighter-rouge">env/*.env</code>, <code class="language-plaintext highlighter-rouge">~/.fdm.conf</code>, <code class="language-plaintext highlighter-rouge">~/.msmtprc</code>.</p>
  </li>
  <li>
    <p>systemd-юниты (симлинки в <code class="language-plaintext highlighter-rouge">~/.config/systemd/user/</code>).</p>
  </li>
  <li>
    <p>Промпты (Jinja2-шаблоны).</p>
  </li>
  <li>
    <p>Dispatch-скрипт и вспомогательные утилиты.</p>
  </li>
</ul>

<h4 id="жизненный-цикл-хоста">Жизненный цикл хоста</h4>

<p>После первого деплоя Ansible свою роль заканчивает. Дальше хост живёт автономно: правки коммитятся в локальный git в <code class="language-plaintext highlighter-rouge">threlium_repo_path</code>, применяются оператором или самим агентом (через <code class="language-plaintext highlighter-rouge">cli_exec</code>, если capability-профиль разрешает). Повторный прогон Ansible — disaster-recovery.</p>

<h4 id="конфигурация-llm">Конфигурация LLM</h4>

<p>Конфигурация LLM (endpoints, модели, таймауты) живёт в <code class="language-plaintext highlighter-rouge">threlium.yaml</code>, который генерируется из структурных Ansible-переменных. Два слота для одной модели с разными параметрами? Пожалуйста:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">llm_endpoints</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">model</span><span class="pi">:</span> <span class="s2">"</span><span class="s">openai/qwen3-35b"</span>
    <span class="na">api_base</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://vllm-host:8000/v1"</span>
    <span class="na">score</span><span class="pi">:</span> <span class="m">0.0</span>
    <span class="na">chat_template_kwargs</span><span class="pi">:</span>
      <span class="na">enable_thinking</span><span class="pi">:</span> <span class="no">false</span>
  <span class="pi">-</span> <span class="na">model</span><span class="pi">:</span> <span class="s2">"</span><span class="s">openai/qwen3-35b"</span>
    <span class="na">api_base</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://vllm-host:8000/v1"</span>
    <span class="na">score</span><span class="pi">:</span> <span class="m">1.0</span>
    <span class="na">chat_template_kwargs</span><span class="pi">:</span>
      <span class="na">enable_thinking</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>Маршрутизация вызовов — по <code class="language-plaintext highlighter-rouge">LitellmRoutingSite</code> (reasoning, enrich_plan, lightrag_llm и т.д.), каждый site может ехать на свой endpoint с разным score. Это все проработано пока скорее как концепт конечно, но уже работает, дорогие вызовы делает reasoning, а дешевые делает GraphRAG.</p>

<hr />

<h3 id="тестирование-e2e-через-docker-и-wiremock">Тестирование: e2e через Docker и WireMock</h3>

<p>Единственный автоматизированный pytest-gate — e2e в <code class="language-plaintext highlighter-rouge">tests/e2e/</code>. Никаких unit-тестов отдельно, просто они для вайбкоженного проекта все равно бесполезны, было 400 юнит-тестов и они просто проверяли, что проект верно не работает. Поведение системы эмерджентно: связка fdm + notmuch + systemd + FSM + LLM — её невозможно адекватно замокать по частям.</p>

<h4 id="тестовый-стек">Тестовый стек</h4>

<p>PlantUML-исходник: Тестовый стек</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
top to bottom direction

rectangle "pytest\n(control node)" as PY

package "Docker Compose" {
  rectangle "sut\nUbuntu 24.04 + полный site.yml\nprivileged, cgroup host\nживой threlium-engine" as SUT
  rectangle "greenmail\nSMTP :3025\nIMAP :3143\nIMAPS :3993" as GM
  rectangle "wiremock\nOpenAI + Matrix mock\nState Extension\nhost :9080 → :8080" as WM
}

PY --&gt; SUT : "compose up/down"
PY --&gt; SUT : "docker exec"
PY --&gt; WM : "Admin API"
SUT --&gt; GM : "docker DNS: greenmail"
SUT --&gt; WM : "docker DNS: wiremock\nthrelium_openai_api_base"
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/07.png" alt="Тестовый стек" /></p>

<p>Тестовый стек</p>

<p><strong>Стратегия baked-образа SUT:</strong> один раз прогоняется полный <code class="language-plaintext highlighter-rouge">site.yml</code> на голом Ubuntu → <code class="language-plaintext highlighter-rouge">docker commit</code> → <code class="language-plaintext highlighter-rouge">threlium/e2e-sut:baked</code>. Дальше тесты стартуют мгновенно из baked-образа.</p>

<p><strong>WireMock с State Extension</strong> обеспечивает изоляцию параллельных сценариев: контекст State привязан к <code class="language-plaintext highlighter-rouge">X-Threlium-Route</code> конкретного теста. Десять xdist-воркеров бьют в один SUT параллельно — каждый со своим notmuch-тредом и своим контекстом в WireMock. Работает с переменным успехом, но мне хватает.</p>

<h4 id="l0-happy-path">L0 happy-path</h4>

<p>PlantUML-исходник: L0 happy-path</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
left to right direction

rectangle "SMTP inject" as SMTP
rectangle "GreenMail\nINBOX" as GM1
rectangle "IMAP bridge\n(IDLE → fetch)" as IMAP
rectangle "ingress" as ING
rectangle "enrich\n(LightRAG aquery)" as ENR
rectangle "reasoning\n(WireMock: tool call)" as REA
rectangle "egress_router" as EGR
rectangle "egress_email\n(msmtp)" as EE
rectangle "GreenMail\nINBOX pytest@" as GM2
rectangle "pytest\nassert In-Reply-To" as ASSERT

SMTP --&gt; GM1
GM1 --&gt; IMAP
IMAP --&gt; ING
ING --&gt; ENR
ENR --&gt; REA
REA --&gt; EGR
EGR --&gt; EE
EE --&gt; GM2
GM2 --&gt; ASSERT
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/08.png" alt="L0 happy-path" /></p>

<p>L0 happy-path</p>

<hr />

<h3 id="отказоустойчивость">Отказоустойчивость</h3>

<ul>
  <li>
    <p><strong>Крэш стадии:</strong> файл остаётся в <code class="language-plaintext highlighter-rouge">new/+unread</code>. <code class="language-plaintext highlighter-rouge">Restart=on-failure</code> повторяет попытку. Sweep (после успеха) перепроверяет backlog.</p>
  </li>
  <li>
    <p><strong>Крэш между</strong> <strong><code class="language-plaintext highlighter-rouge">rename(2)</code></strong> <strong>и Xapian-commit:</strong> файл уже в <code class="language-plaintext highlighter-rouge">cur/</code>, но notmuch думает, что он в <code class="language-plaintext highlighter-rouge">new/</code>. <code class="language-plaintext highlighter-rouge">settle_recovery_for_stage()</code> на старте воркера лечит через <code class="language-plaintext highlighter-rouge">from_maildir_flags()</code>.</p>
  </li>
  <li>
    <p><strong>Крэш моста:</strong> <code class="language-plaintext highlighter-rouge">sys.exit(1)</code> + <code class="language-plaintext highlighter-rouge">Restart=on-failure</code>. Курсор восстанавливается из notmuch.</p>
  </li>
  <li>
    <p><strong>Крэш движка:</strong> все submit’ы получают <code class="language-plaintext highlighter-rouge">BindsTo</code> на <code class="language-plaintext highlighter-rouge">threlium-engine.service</code>. При рестарте движка воркеры перезапускаются автоматически.</p>
  </li>
  <li>
    <p><strong>LightRAG drain прервался:</strong> тег <code class="language-plaintext highlighter-rouge">+lightrag_indexed</code> не поставлен → следующий drain повторит <code class="language-plaintext highlighter-rouge">ainsert</code>. LightRAG dedup гарантирует безопасность.</p>
  </li>
</ul>

<p>Отдельной стадии <code class="language-plaintext highlighter-rouge">errors/</code> и error-mail нет. Сбои — structured log в journald и ненулевой exit code. Просто и предсказуемо, а весь процесс “мышления” видно просто в почте.</p>

<hr />

<h3 id="админка-cockpit--caddy--roundcube--dovecot">Админка: Cockpit + Caddy + Roundcube + Dovecot</h3>

<p>Раз каждое событие — письмо, логично дать оператору смотреть «мысли» агента через обычный почтовый веб-интерфейс. Для этого на target поднимается стек из четырёх компонентов, которые вместе превращаются в полноценную админ-панель.</p>

<h4 id="dovecot-imap-поверх-maildirов-стадий">Dovecot: IMAP поверх Maildir’ов стадий</h4>

<p>Dovecot подключается к тем же Maildir’ам, что и FSM. Единственный конфиг — drop-in <code class="language-plaintext highlighter-rouge">99-threlium-webmail.conf</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>namespace inbox {
  inbox = yes
  location = maildir:$THRELIUM_HOME/stages:LAYOUT=fs:DIRNAME=Maildir
}
</code></pre></div></div>

<p>Каждая стадия FSM (<code class="language-plaintext highlighter-rouge">ingress</code>, <code class="language-plaintext highlighter-rouge">enrich</code>, <code class="language-plaintext highlighter-rouge">reasoning</code>, …) становится IMAP-папкой. Дополнительно настроен virtual namespace — виртуальная папка «All», которая собирает письма из всех стадий в единую ленту. Авторизация — через PAM (тот же POSIX-пользователь, что и агент).</p>

<p>ACL выставлены в режим read-only: <code class="language-plaintext highlighter-rouge">lr</code> (list + read) — можно просматривать, но не менять флаги, не удалять, не вставлять. Maildir пишут только Threlium и notmuch, Dovecot — чисто на чтение.</p>

<h4 id="roundcube-веб-интерфейс-для-почты-агента">Roundcube: веб-интерфейс для почты агента</h4>

<p>Roundcube подключается к локальному Dovecot (localhost:143, plaintext — всё на loopback). SMTP отключён — это read-only интерфейс. Из коробки настроен:</p>

<ul>
  <li>
    <p>Режим <code class="language-plaintext highlighter-rouge">threads</code> с сортировкой по дате — цепочки рассуждений видны как нити писем.</p>
  </li>
  <li>
    <p>Виртуальная папка <code class="language-plaintext highlighter-rouge">Virtual/All</code> подключена как архив и в <code class="language-plaintext highlighter-rouge">default_folders</code>.</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">mail_read_time = -1</code> — Roundcube не ставит флаг «прочитано» при открытии (в паре с ACL Dovecot).</p>
  </li>
  <li>
    <p>SQLite для хранения сессий — никакого MySQL/PostgreSQL.</p>
  </li>
</ul>

<p>В Roundcube только надо в настройках показ папки Virtual/All включить и перейти в нее для просмотра всех тредов размышлений агента в виде обычных писем. Это удобно еще и потому, что когда отладка, можно просто скопировать письмо со всеми заголовками и агента попросить что то проверить с этим письмом связанное.</p>

<h4 id="caddy-единый-edge-proxy">Caddy: единый edge-proxy</h4>

<p>Caddy работает как точка входа, объединяя Cockpit и Roundcube на одном порту:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>:8080 {
    handle_path /webmail/* {
        root * /usr/share/roundcube
        php_fastcgi unix//run/php/php-fpm.sock
        file_server
    }
    route * {
        reverse_proxy 127.0.0.1:9090 {
            transport http { tls_insecure_skip_verify }
        }
    }
}
</code></pre></div></div>

<p>Маршрутизация простая: <code class="language-plaintext highlighter-rouge">/webmail/*</code> → Roundcube через PHP-FPM, всё остальное → Cockpit на <code class="language-plaintext highlighter-rouge">:9090</code>. TLS — <code class="language-plaintext highlighter-rouge">tls internal</code> (self-signed) для прода, отключён в e2e. Порт настраивается через <code class="language-plaintext highlighter-rouge">threlium_mail_archive_caddy_bind_port</code>.</p>

<h4 id="cockpit-системная-админ-панель-с-roundcube-внутри">Cockpit: системная админ-панель с Roundcube внутри</h4>

<p>Cockpit даёт из коробки: терминал, просмотр journald-логов (а значит всех логов агента), управление systemd-юнитами (можно рестартовать стадии, мосты, engine), мониторинг ресурсов, файловый менеджер (если доступен <code class="language-plaintext highlighter-rouge">cockpit-files</code> из backports).</p>

<p>В Cockpit регистрируется кастомный пакет <code class="language-plaintext highlighter-rouge">threlium-mail-archive</code> — это <code class="language-plaintext highlighter-rouge">manifest.json</code> + <code class="language-plaintext highlighter-rouge">index.html</code> с iframe на <code class="language-plaintext highlighter-rouge">/webmail/</code>. В итоге Roundcube появляется прямо как вкладка в Cockpit. Оператор видит в одном окне: системные метрики, логи, юниты и полную переписку агента. Это все вовсе без сложной инфры, оно еще и не ест ресурсов почти.</p>

<p>Cockpit слушает только на <code class="language-plaintext highlighter-rouge">127.0.0.1:9090</code> — наружу не торчит. Origin-проверка (защита от CSWSH) настраивается через <code class="language-plaintext highlighter-rouge">threlium_mail_archive_cockpit_origins_extra</code> в host_vars. Специальный oneshot-юнит <code class="language-plaintext highlighter-rouge">threlium-cockpit-tls-clean.service</code> чистит <code class="language-plaintext highlighter-rouge">/run/cockpit/tls</code> перед стартом Cockpit — workaround для известного бага с <code class="language-plaintext highlighter-rouge">cockpit-certificate-ensure</code>.</p>

<h4 id="как-это-выглядит-вместе">Как это выглядит вместе</h4>

<p>PlantUML-исходник: Админка: стек Cockpit/Caddy/Roundcube/Dovecot</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
left to right direction

actor "Оператор" as OP

package "Target host" {
  package "Caddy :8080" as CADDY {
    rectangle "/webmail/*" as WM
    rectangle "/* (всё остальное)" as ROOT
  }

  rectangle "Roundcube\nPHP-FPM" as RC
  rectangle "Cockpit :9090\n(loopback only)" as CP
  rectangle "Dovecot :143\n(loopback, PAM)" as DOV

  WM --&gt; RC
  ROOT --&gt; CP

  RC --&gt; DOV : "IMAP localhost:143"

  package "$THRELIUM_HOME/stages/" {
    rectangle "ingress/Maildir" as MD1
    rectangle "enrich/Maildir" as MD2
    rectangle "reasoning/Maildir" as MD3
    rectangle "…/Maildir" as MDN
  }

  DOV --&gt; MD1 : "read-only\nACL: lr"

  rectangle "Cockpit package\nthrelium-mail-archive\niframe → /webmail/" as PKG
  CP -- PKG
  PKG ..&gt; WM : "iframe src"
}

OP --&gt; CADDY : "браузер"
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/09.png" alt="Админка: стек Cockpit/Caddy/Roundcube/Dovecot" /></p>

<p>Админка: стек Cockpit/Caddy/Roundcube/Dovecot</p>

<p>Весь стек включается одной переменной <code class="language-plaintext highlighter-rouge">threlium_mail_archive_web_enabled: true</code> (по умолчанию включён) и деплоится только при полном прогоне (<code class="language-plaintext highlighter-rouge">--tags deploy</code>). При <code class="language-plaintext highlighter-rouge">--tags refresh</code> веб-стек не трогается.</p>

<hr />

<h3 id="ansible-playbook-структура-и-режимы-работы">Ansible playbook: структура и режимы работы</h3>

<p>Я уже упоминал push-модель развёртывания, но стоит рассказать подробнее о самом плейбуке — он устроен осознанно непохоже на типичный Ansible-проект.</p>

<h4 id="один-плейбук-одна-роль">Один плейбук, одна роль</h4>

<p>Весь деплой — единственный файл <code class="language-plaintext highlighter-rouge">ansible/playbooks/site.yml</code>. Задачи живут прямо в нём, а не в <code class="language-plaintext highlighter-rouge">roles/threlium/tasks/</code>. Почему? Файл короткий, читается как последовательный сценарий, а перенос в роль дал бы <code class="language-plaintext highlighter-rouge">include_role</code> с тем же числом строк и сломал бы относительные пути. Роль <code class="language-plaintext highlighter-rouge">threlium</code> используется только для хранения переменных, шаблонов, файлов и дефолтов.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ansible/
  playbooks/
    site.yml                    # единственный сценарий
    tasks/
      refresh.yml               # узкий тег: чистка + рестарт
      mail_archive_web.yml      # веб-стек (Cockpit/Caddy/…)
      mail_archive_web_acceptance.yml
      ssh_hardening.yml
  roles/threlium/
    defaults/main.yml           # дефолтные переменные
    vars/main.yml               # канон FSM-стадий
    files/
      scripts/                  # Python-код FSM + bash-скрипты
      prompts/                  # Jinja2-промпты для LLM
      mail-archive/             # статика: dovecot-virtual
    templates/
      config/                   # fdm.conf, msmtprc, threlium.yaml
      systemd/user/             # шаблоны unit-файлов
      mail-archive/             # Caddyfile, cockpit.conf, …
      env/threlium.env.j2
      pyproject.toml.j2
  host_vars/                    # per-host: LLM endpoints, секреты
  group_vars/                   # общие переменные и e2e-оверрайды
  inventory/                    # hosts (прод и e2e)
</code></pre></div></div>

<h4 id="фазы-деплоя">Фазы деплоя</h4>

<p>Каждая фаза — предусловие для следующей:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>#</td>
      <td>Фаза</td>
      <td>Что делает</td>
    </tr>
    <tr>
      <td>1</td>
      <td>Assert</td>
      <td>Проваливает прогон <strong>до</strong> изменений при пустых обязательных переменных</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Bootstrap ОС</td>
      <td><code class="language-plaintext highlighter-rouge">apt</code>: fdm, msmtp, notmuch, python3, python3-venv</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Каталог артефактов</td>
      <td><code class="language-plaintext highlighter-rouge">threlium_repo_path/</code> + идемпотентный <code class="language-plaintext highlighter-rouge">git init</code></td>
    </tr>
    <tr>
      <td>4</td>
      <td>Раскладка <code class="language-plaintext highlighter-rouge">$THRELIUM_HOME</code></td>
      <td>Стадийные Maildir’ы из <code class="language-plaintext highlighter-rouge">vars/main.yml</code></td>
    </tr>
    <tr>
      <td>5</td>
      <td>Код FSM</td>
      <td><code class="language-plaintext highlighter-rouge">copy</code> Python-пакета + dispatch-скрипт</td>
    </tr>
    <tr>
      <td>6</td>
      <td>Конфиги</td>
      <td><code class="language-plaintext highlighter-rouge">template</code>: fdm.conf, msmtprc, threlium.yaml, threlium.env</td>
    </tr>
    <tr>
      <td>7</td>
      <td>Unit-файлы</td>
      <td>Шаблоны systemd: engine, work@, sweep@, bridge@</td>
    </tr>
    <tr>
      <td>8</td>
      <td>Симлинки</td>
      <td><code class="language-plaintext highlighter-rouge">~/.fdm.conf</code>, <code class="language-plaintext highlighter-rouge">~/.msmtprc</code>, все юниты в <code class="language-plaintext highlighter-rouge">~/.config/systemd/user/</code></td>
    </tr>
    <tr>
      <td>9</td>
      <td>Venv + pip</td>
      <td><code class="language-plaintext highlighter-rouge">pyproject.toml.j2</code> → target, <code class="language-plaintext highlighter-rouge">pip install .</code></td>
    </tr>
    <tr>
      <td>10</td>
      <td>linger + start</td>
      <td><code class="language-plaintext highlighter-rouge">loginctl enable-linger</code> + <code class="language-plaintext highlighter-rouge">daemon-reload</code> + <code class="language-plaintext highlighter-rouge">state: started</code></td>
    </tr>
    <tr>
      <td>11</td>
      <td>Веб-стек</td>
      <td>Cockpit + Caddy + Roundcube + Dovecot (если включён)</td>
    </tr>
    <tr>
      <td>12</td>
      <td>Acceptance</td>
      <td>Сквозная самопроверка: Maildir’ы, юниты, notmuch, fdm.conf, Python</td>
    </tr>
    <tr>
      <td>13</td>
      <td>Bundle</td>
      <td><code class="language-plaintext highlighter-rouge">tar.gz</code> снимок установки → fetch на control node</td>
    </tr>
  </tbody>
</table>

<h4 id="два-закона-идемпотентности">Два закона идемпотентности</h4>

<p>Плейбук разделяет два класса операций:</p>

<p><strong>Класс A — внешние зависимости</strong> (apt, pip): <code class="language-plaintext highlighter-rouge">state: present</code> — «install if missing». Стандартная идемпотентность Ansible. Не обновляет уже установленное.</p>

<p><strong>Класс B — артефакты Threlium</strong> (код, конфиги, юниты, симлинки): <strong>перетирание</strong> каждый прогон. Файл на target сравнивается с репо и перезаписывается при расхождении. Никаких <code class="language-plaintext highlighter-rouge">creates:</code> или маркерных файлов — через baked-образ они превращаются в зашитый T₀.</p>

<p>Исключения из класса B: локальный <code class="language-plaintext highlighter-rouge">.git</code> (не стирать историю оператора) и физическая раскладка durable Maildir’ов (не пересоздавать event store с данными).</p>

<h4 id="два-режима-deploy-и-refresh">Два режима: deploy и refresh</h4>

<p>PlantUML-исходник: Deploy vs Refresh</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>@startuml
top to bottom direction

package "--tags deploy (полный прогон)" {
  rectangle "apt: fdm, notmuch,\npython3, cockpit, caddy…" as D1
  rectangle "Каталоги + Maildir'ы" as D2
  rectangle "copy: Python-код FSM" as D3
  rectangle "template: конфиги,\nunit-файлы, env" as D4
  rectangle "pip install + venv" as D5
  rectangle "Веб-стек\n(Cockpit/Caddy/…)" as D6
  rectangle "Acceptance" as D7
  rectangle "Bundle" as D8
  D1 --&gt; D2
  D2 --&gt; D3
  D3 --&gt; D4
  D4 --&gt; D5
  D5 --&gt; D6
  D6 --&gt; D7
  D7 --&gt; D8
}

package "--tags refresh (узкий прогон)" {
  rectangle "Остановка engine + мостов" as R1
  rectangle "Синхронизация:\nscripts/, env,\nшаблоны конфигов/юнитов" as R2
  rectangle "daemon-reload" as R3
  rectangle "Чистка Maildir/notmuch/\nLightRAG" as R4
  rectangle "Рестарт user-units" as R5
  R1 --&gt; R2
  R2 --&gt; R3
  R3 --&gt; R4
  R4 --&gt; R5
}
@enduml
</code></pre></div></div>

<p><img src="/blog/assets/images/ai-agent-mail-systemd-llm/10.png" alt="Deploy vs Refresh" /></p>

<p>Deploy vs Refresh</p>

<p><strong>deploy</strong> — полный bootstrap: apt, venv, pip, веб-стек, acceptance, bundle. Используется для нового хоста или disaster-recovery.</p>

<p><strong>refresh</strong> — узкий прогон: синхронизация кода и конфигов с control node + сброс Maildir/notmuch/LightRAG + рестарт user-units. <strong>Без</strong> apt, <strong>без</strong> pip, <strong>без</strong> веб-стека. Основной режим для e2e-тестов: baked-образ SUT переиспользуется, refresh накатывает актуальные артефакты идемпотентно.</p>

<p>Разметка тегов — три контракта:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Разметка</td>
      <td>Полный прогон</td>
      <td><code class="language-plaintext highlighter-rouge">--tags refresh</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deploy</code> только</td>
      <td>да</td>
      <td>нет</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deploy</code> + <code class="language-plaintext highlighter-rouge">refresh</code></td>
      <td>да</td>
      <td>да</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">never</code> + <code class="language-plaintext highlighter-rouge">refresh</code></td>
      <td>нет</td>
      <td>да</td>
    </tr>
  </tbody>
</table>

<h4 id="после-bootstrap-автономная-эволюция">После bootstrap: автономная эволюция</h4>

<p>Ключевое отличие от типичных Ansible-проектов: <strong>плейбук — не governor хоста</strong>. После bootstrap ответственность переходит локальному <code class="language-plaintext highlighter-rouge">git</code> в <code class="language-plaintext highlighter-rouge">threlium_repo_path</code>.</p>

<ul>
  <li>
    <p><strong>Оператор</strong> правит скрипт/конфиг прямо на target → <code class="language-plaintext highlighter-rouge">daemon-reload</code> → <code class="language-plaintext highlighter-rouge">git commit</code>. Симлинки сразу видят новое.</p>
  </li>
  <li>
    <p><strong>Агент</strong> — через <code class="language-plaintext highlighter-rouge">cli_exec</code> в рамках capability-профиля. Может менять свои промпты, конфиги, даже Python-код, коммитя изменения в локальный git.</p>
  </li>
  <li>
    <p><strong>Обратной синхронизации</strong> target → control нет и не предполагается. Каждая установка эволюционирует независимо.</p>
  </li>
</ul>

<p>Повторный полный <code class="language-plaintext highlighter-rouge">ansible-playbook site.yml</code> на живом хосте — <strong>только disaster-recovery</strong>. Он перетрёт локальные коммиты. Для штатного обновления кода — локальные правки или refresh.</p>

<h4 id="канон-стадий--одна-точка-правды">Канон стадий — одна точка правды</h4>

<p>Все FSM-стадии определены в единственном месте: <code class="language-plaintext highlighter-rouge">roles/threlium/vars/main.yml</code> (<code class="language-plaintext highlighter-rouge">threlium_fsm_mailbox_stages</code>). Все задачи плейбука — циклы по этому списку. Добавить стадию = добавить строчку. Рассинхронизация между Maildir’ами, fdm.conf и systemd-юнитами невозможна по конструкции.</p>

<hr />

<h3 id="что-имеем-в-итоге">Что имеем в итоге</h3>

<p>Threlium — самохостный AI-агент, построенный из Unix-примитивов:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Компонент</td>
      <td>Реализация</td>
    </tr>
    <tr>
      <td>Хранилище событий</td>
      <td>Maildir (файлы на диске)</td>
    </tr>
    <tr>
      <td>Индекс</td>
      <td>notmuch (Xapian)</td>
    </tr>
    <tr>
      <td>Очередь</td>
      <td>Maildir <code class="language-plaintext highlighter-rouge">new/</code> → <code class="language-plaintext highlighter-rouge">cur/</code></td>
    </tr>
    <tr>
      <td>Оркестрация</td>
      <td>systemd –user</td>
    </tr>
    <tr>
      <td>Маршрутизация</td>
      <td>fdm (<code class="language-plaintext highlighter-rouge">~/.fdm.conf</code>)</td>
    </tr>
    <tr>
      <td>Рассуждение</td>
      <td>litellm + tool calls</td>
    </tr>
    <tr>
      <td>Память</td>
      <td>LightRAG (NanoVectorDB + NetworkX)</td>
    </tr>
    <tr>
      <td>Каналы</td>
      <td>IMAP IDLE, Telegram Bot API, Matrix (nio)</td>
    </tr>
    <tr>
      <td>Промпты</td>
      <td>Jinja2 шаблоны</td>
    </tr>
    <tr>
      <td>Развёртывание</td>
      <td>Ansible push-модель</td>
    </tr>
    <tr>
      <td>Конфигурация</td>
      <td>pydantic-settings + YAML (<code class="language-plaintext highlighter-rouge">threlium.yaml</code>)</td>
    </tr>
    <tr>
      <td>Тестирование</td>
      <td>pytest e2e + Docker + WireMock + GreenMail</td>
    </tr>
    <tr>
      <td>Безопасность CLI</td>
      <td>cli_intent (политика) → cli_exec (песочница)</td>
    </tr>
  </tbody>
</table>

<p>Вся система — один Python-пакет с единым venv, один <code class="language-plaintext highlighter-rouge">systemd --user</code> manager, один notmuch union-индекс. Никаких Docker-compose’ов в продакшене, никаких баз данных, никаких внешних брокеров. Файлы на диске, процессы в systemd, промпты в Jinja2.</p>

<p>Работает ли это? Работает. Я пишу агенту письмо — он думает, обогащает контекст из графа знаний, рассуждает, при необходимости выполняет команды (с подтверждением или без), и отвечает. Telegram и Matrix — пока не проверял :) Все каналы симметричны, история хранится вечно, контекст глобален.</p>

<p><em>P.S. Рекомендую LLM для консультаций при настройке. Особенно когда дебажишь, почему notmuch insert повесил</em> <em><code class="language-plaintext highlighter-rouge">+unread</code>, а dispatch-скрипт не поднял воркер. Оказалось — опечатка в</em> <em><code class="language-plaintext highlighter-rouge">folder:</code></em> <em>термине. Эта конструкция домашнего агента совершенно прозрачна для отладки и модификации другими агентами! :)</em></p>

<p>Опубликован <a href="https://github.com/3DRaven/threlium">тут</a></p>

<p>UPD: случайно не добавил в репу папку с скриптами :) Добавлено.</p>]]></content><author><name></name></author><category term="AI" /><category term="agent" /><category term="linux" /><category term="selfhosted" /><category term="kiss" /><summary type="html"><![CDATA[В прошлых статьях я строил домашнее облако на Proxmox. Теперь внутри него живёт кое-что поинтереснее — полностью автономный AI-агент, которого я могу пнуть письмом из обычного почтового клиента или сообщением в Telegram, и он ответит, подумав. Причём подумав по-настоящему: с многошаговым рассуждением, долговременной памятью и возможностью выполнять команды. Зовут его Threlium, и устроен он чертовски необычно и просто, например может модифицировать сам себя. Зачем, почему и что получилось — расскажу ниже.]]></summary></entry><entry><title type="html">Как сделать книгу за пятьдесят рублей</title><link href="https://3draven.github.io/blog/2026/01/10/book-for-fifty-rubles.html" rel="alternate" type="text/html" title="Как сделать книгу за пятьдесят рублей" /><published>2026-01-10T21:32:42+00:00</published><updated>2026-01-10T21:32:42+00:00</updated><id>https://3draven.github.io/blog/2026/01/10/book-for-fifty-rubles</id><content type="html" xml:base="https://3draven.github.io/blog/2026/01/10/book-for-fifty-rubles.html"><![CDATA[<p><img src="/blog/assets/images/book-for-fifty-rubles/cover.gif" alt="" /></p>

<p>Много лет назад я опубликовал этот материал, но платформа умерла, сохраню тут этот артефакт далекого прошлого, на мой взгляд до сих пор актуальный. Многим нравится держать книгу в руках и в последнее время мне тоже, особенно после того как ее сам сделал. При этом на самом деле это довольно дешево, сделать книгу. Плюс, поди найди в магазине нужную книгу. Я вот одну только в Москве нашел, у издателя. Вот и попробуем сделать настоящий шитый переплет, даже намного лучше типичного дешевого, сами.</p>

<h3 id="подготовка">Подготовка</h3>

<p>Сразу стоит сказать, что если исходный файл содержит всего несколько десятков страниц, то лучше его распечатать в виде брошюры, многие просмотрщики и редакторы умеют это делать сами, просто в настройках печати надо указать.<br />
Материалы, которые понадобятся для создания книги в хорошем, шитом переплете как в магазине (а скорее всего гораздо качественнее):</p>

<h4 id="бумага">Бумага</h4>

<p>Это моя находка. Надо найти не просто обычную белую офисную бумагу 80-85 г/м2, из нее получится толстенный, неудобный талмуд, надо найти бумагу для «рукописных работ» или ее называют «писчая бумага», или «потребительская», или «для машинописных работ» (эта особенно хороша, волокна длинные от этого она прочнее). Это бумага от 40 до 65 г/м2 плотности (подобрать по вкусу). Ее найти легко, надо просто спросить в канцелярских товарах. Когда держишь ее в руках, кажется, что она очень тонкая и будет неудобна, но когда будет переплет (закрепленная с одной стороны, толстая пачка листов), будет очень удобно, прям книга. Причем эта бумага потоньше чем обычная книжная и от того книга будет тоньше, что удобно для самодельных книг, объединяющих несколько томов. Но это не все. Есть одно большое достоинство. Эта бумага стоит 60-90р за 500 листов. Считаем. Печать одного листа будет стоить на своем принтере примерно 28 копеек с бумагой и это максимум, если заправлять картридж самому, если не самому, то 50 копеек за лист. Допустим книга 500 страниц, это будет ~128 листов (потом объясню почему). Стоимость распечатки книги, 36р или 70р. Еще надо две картонки А5, кусок ткани, пару метров ниток, тридцать сантиметров тесемок, клей ПВА и пару листов ватмана А4 (можно просто толстой бумаги «для черчения» она бывает гладкая, это красивее). Выходит, с такой бумагой себестоимость всей книги будет ~50р, плюс ваше время, если не брать сушку клея и ходьбу по магазинам в первый раз, часа два, и относительно времени пользования книгой это мелочь. Может себестоимость не первой книги такая, так как придется покупать пачку картона и пр., но в среднем «на закупку» именно так, а многие из материалов по дому можно найти или еще где. В общем издатели/перекупщики имеют выручку минимум десятикратную от себестоимости исходного продукта. Хотя будут вопить, что там и зарплаты и еще куча всего…ну вот что бы это все не оплачивать, гораздо дешевле сделать самому. Последняя книга, что я сделал, стоила в магазине 1100р. Причем в интернет магазине, в обычном такую уже не найти, а значит ждать доставку, а это дольше чем сделать самому. Я сделал ее за 50р. Стоит повозится, первый раз долго, но потом все быстрее.</p>

<h4 id="картон">Картон</h4>

<p>Можно детский А5. Его клеем ПВА склеить в пару-тройку слоев и будет основа для жесткого переплета. Но продают и переплетный картон. Я в своем городе нашел одно такое место. Надо смотреть фирмы, торгующие бумагой, обзвонить несколько или на сайтах пошариться. Найти не просто, но можно. Переплетный картон толще детского, но продается листами 70*100см, что неудобно.</p>

<h4 id="ткань">Ткань</h4>

<p>Для оклейки переплета. Можно пользоваться и обоями (я не пробовал, но пишут, что проще), можно в той же бумажной фирме купить оклейку для переплетов. Но ткань дешевле и если клей не лить струей, то все получится отлично. Ткань надо х/б (другую я не пробовал, а эту в руках держать приятно, но лучше была бы джинса наверное) и плотную, что бы не тянулась особенно. В общем можно пустить на это дело старую одежку. Если будет тоненькая, то клей через нее пропитается легко и это плохо. Сначала от клея могут быть пятна, потом они пропадут когда высохнет но отсвечивать будут. Если же клей намазать тонким слоем, то он через ткань не пройдет и вообще виден не будет что бы так получилось, наносите клей кисточкой и так что бы ПВА не был белыми разводами, под прессом он склеится намного лучше чем без него, так что много не надо.</p>

<h4 id="нитки">Нитки</h4>

<p>Армированные. Они не рвутся и приятно блестят в переплете.</p>

<h4 id="тесьма">Тесьма</h4>

<p>Просто стропы в тканях купите, тканевая лента, шириной 0.5-1 см или можно взять ткань, согнуть и прострочить по краю. Лучше если они будут потоньше, тогда они не будут выпирать через форзац будущей книги. Купить всегда лучше чем делать самому, во-первых качественнее, а во вторых усилий меньше, за создание переплета и так непросто взяться в первый раз. Так что лучше купить максимум того, что можете, в следующий раз будете сами все делать, когда технологию отработаете, но затраты невелики вообще-то.</p>

<h4 id="клей-пва">Клей ПВА</h4>

<p>Канцелярский тоже говорят пойдет, но он хуже. Для намазывания клея удобно использовать кисточку, иначе ткань пойдет не проклеенными пузырями, кисточкой клей будет нанесен равномерно. Клей надо мазать так что бы не было белых разводов, пусть лучше кажется, что его мало чем потом форзац пойдет волнами, а ткань еле заметными но все же пятнами. Мне на одну книгу надо где то 25г клея. Ждать полного высыхания клея не надо. Достаточно что бы он “схватился” под прессом, минут за десять. Но для изготовления картона, который я делаю заранее, я жду до конца. Тогда картон становится прочным и с ним удобно работать. В самом конце, когда собранная книга будет под прессом, вот тогда надо дождаться когда клей полностью высохнет во всей книге. Если так делать, это экономит много времени.</p>

<h4 id="пара-листов-плотной-бумаги-а4">Пара листов плотной бумаги А4</h4>

<p>Плотность более 130 г/м2. Как раз тот самый ватман. Из него будут сделаны форзацы книги. Тут лучше бумага поплотнее. Форзацы часть армирования книги.</p>

<h3 id="инструменты">Инструменты</h3>

<h4 id="пресс">Пресс</h4>

<p>Мой первый пресс выглядел как доска и две гантели. Потом я сходил в хозяйственный магазин и купил пару болтов и к ним пару гаек с барашками, что бы руками закручивать. Доски сверлятся и вот пресс готов. Позже мне на заводе сделали пресс из двух железных пластин и все тех же болтов с барашками, но уже четырех. Доски не хуже, а может и лучше, они не гнуться при затягивании пресса и не повредят бумагу краями. Доски обработать рубанком или взять толстую фанеру/оргстекло. Ширину доски надо побольше, но на всю ширину книги не обязательно, в крайнем случае можно под него фанеру подложить или вообще обойтись. Сейчас у меня металлический пресс размером больше книги с четырьмя болтами по углам. Надо знать, что затягивать очень сильно все равно не придется, достаточно и доски с гантелями. Еще, иногда можно найти объявление о продаже набора доя переплета, советского, такие были, там есть пресс, я в итоге такой нашел.</p>

<h4 id="острый-нож">Острый нож</h4>

<p>Он нужен что бы ровно обрезать край книги. Для тонкой бумаги возможно нож не подойдет, у меня нож был недостаточно остр и порвал листы, но ножами режут. Лучше использовать строительный, со сменными лезвиями. Я думаю попробовать круглый, дисковый нож для резки обоев, он не будет рвать листы, зацепляясь за них. Но и обрезать в конечном итоге не так уж и жизненно важно, можно обойтись, можно с готовым книжным блоком (еще без обложки) прийти в типографию и они на гильотине срежут край идеально, что увеличит себестоимость наверное раза в три. Я же подошел к вопросу по другому и зажав в прессе книжный блок (страницы без обложки), выровнял край на шлифовальном круге, при сжатии бумага как деревяшка. Но ножом край будет более качественно отрезан, как и гильотиной. Можно и не резать вовсе.</p>

<h4 id="мелочи">Мелочи</h4>

<p>Линейка (лучше уголок), карандаш, кусок мыла или мела (для раскроя ткани, прямоугольник нарисовать), кисточка для клея, толстая иголка или шило (без них можно обойтись), тонкая иголка, утюг.</p>

<h3 id="программы">Программы</h3>

<p>Для подготовки к печати исходных данных нужны будут программы. Это называется допечатная подготовка и спуск полос, за это в типографии берут денег немало, что повышает себестоимость в несколько раз. Буду делать все в ubuntu/linux, но либо эти же программы, либо аналоги есть в windows:</p>

<ol>
  <li>
    <p>Scantailor. Программа понадобится вам если у вас скан книги с сдвоенными страницами. Она их поделит пополам в полуавтоматическом режиме. Так же она страницы выровняет, повернет и настроит поля. Поля лучше сделать к середине книги побольше, а к внешнему краю поменьше. В общем очень удобно пользоваться. Но ей надо выдать страницы в tiff формате.</p>
  </li>
  <li>
    <p>Djview4. Программа умеет сохранять djvu в tiff и pdf. Думаю аналогов миллион. Так же в tiff надо будет сохранить и pdf, я для этого перевожу его сначала в djvu. Но tiff нужен будет только если надо будет воспользоваться scantailor. Программа Djview4 хороша также тем, что быстро работает в отличие от некоторых консольных утилит.</p>
  </li>
  <li>
    <p>Tiffcp умеет собирать отдельные картинки tif в один многостраничный документ tiff.</p>
  </li>
  <li>
    <p>Pdf2ps или pdftops и наоборот, а так же pdf2djvu. Это преобразователи pdf, ps и djvu форматов между собой. Есть также и djvups. tiff2ps конвертирует tiff в  ps формат.</p>
  </li>
  <li>
    <p>Psutils. Набор утилит для работы с PostScript файлами. Вот именно в ps формат потом и надо будет конвертировать любой файл. Именно эти утилиты делают «спуск полос». Нам из всего набора понадобится две утилиты (более подробно в <a href="http://www.opennet.ru/base/sys/psutils.txt.html">этой статье</a>). Psbook. Это утилита, которая делит книгу на сигнатуры. Сигнатура это тетрадка, из таких отдельных тетрадок состоит переплет «настоящей» книги. Размер сигнатуры должен быть кратен четырем, так как на одном формате А4, можно напечатать четыре страницы формата А5. То есть по две страницы с каждой стороны. Вот потому 128 листов в 500 страничной книге. Psbook  добавит в конце будущей книги, а не просто списка страниц, недостающие до кратности страницы и пересортирует страницы в списке так что бы при печати вышли тетрадки. Вот, кстати, откуда берутся «страницы для заметок» это просто «округление» до кратности. Psnup. Это утилита, которая разместит по две страницы на одном листе. Psmerge. Это утилита, объединяющая несколько ps файлов в один. Psselect. Позволяет выбирать из книг в ps формате отдельные страницы или выбирать страницы в обратном порядке.</p>
  </li>
  <li>
    <p>Impose+. Набор утилит для подготовки книг/буклетов к печати. Используют psutils как основу. Утилита psbl, из состава impose+, умеет делать сигнатуры и прекрасно регулирует поля.</p>
  </li>
  <li>
    <p>Нужен будет также просмотрщик ps файлов. Я пользовался evince. Но можно итоговый файл просто конвертировать обратно в djvu или pdf, что бы потом распечатать. С просмотрщиком ps проще.</p>
  </li>
</ol>

<h3 id="общая-схема-действий">Общая схема действий</h3>

<p><img src="/blog/assets/images/book-for-fifty-rubles/01.gif" alt="" /></p>

<p>В схеме два варианта начала. Имеем исходный djvu или pdf файл. Далее все зависит от того, надо ли нам резать страницы пополам, то есть сдвоенны ли страницы на скане книги. В конечном итоге все придет к ps файлу, который уже превращается в книгу, печатается и переплетается. Способов превращения в книгу два, первый сам подбирает поля и часто работает, но если проблемы, то есть второй с гибкими регулировками полей, я пользуюсь вторым.</p>

<p>## Подготовка и печать книги</p>

<p>У меня есть книга в формате djvu. У нее спаренные и цветные страницы:</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/02.png" alt="" /></p>

<p>Далее, что бы было проще, по действиям, но можно и с применением конвеера и даже скрипт накатать, кому надо тот понял. Если вам надо разрезать страницы книги пополам:</p>

<ol>
  <li>
    <p>С помощью djview конвертируем книгу в многостраничный tiff файл. Книга, что я делаю, состоит из двух томов. Я их объединил в один шестисотстраничный том, просто скопировав tif страницы-картинки в одну папку. Бумага не офисная, том будет по толщине как обычная книга (позже я решил делать другую книгу, собрал ее из отдельных, маленьких статей на нужную мне тему, что, кстати, тоже возможно и удобно).</p>
  </li>
  <li>
    <p>Берем Scantailor и режем страницы пополам. Больше нам от него особо ничего не надо, так как скан книги очень качественный, если надо, там все просто. Единственное с полями он пока работает плохо.</p>
  </li>
  <li>
    <p>Получившийся после Scantailor набор tif картинок собираем в tiff файл с помощью <code class="language-plaintext highlighter-rouge">ttiffcp -c none ./*.tif ./output.tiff</code>. Эта утилита собирает много картинок в один многостраничный файл и работает быстро, так как сжатие в параметрах отключено. Картинки должны быть в текущей директории и названия имен файлов должны быть сортированы по порядку страниц. Например <code class="language-plaintext highlighter-rouge">1.tif, 2.tif,...,10.tif</code> и т. д. Обратите внимание сортировку страниц 1 и 10. Они обе начинаются с 1 и это может испортить порядок, а может не испортить. Страницы в итоговом файле будут вставлены в соответствии с порядком файлов, отсортированных по именам.  Djview4 и  Scantailor имена сделают сразу сортируемыми правильно. Файлы без сжатия будут огромные, например в сумме 1.5Гб, но зато скорость будет выше так как не надо будет на каждом этапе пережимать их.</p>
  </li>
  <li>
    <p>Конвертируем полученный tiff в ps с помощью <code class="language-plaintext highlighter-rouge">tiff2ps -a2 ./output.tiff &gt; output.ps</code></p>
  </li>
</ol>

<p>Если резать страницы не надо, то просто конвертируем в ps формат, исходный djvu, с переводом его в монохромный вид (книга была цветная) <code class="language-plaintext highlighter-rouge">djvups -mode=bw -verbose ./in.djvu ./out1.ps</code>. Вместо mode можно задавать и другие режимы вывода, для лазерного монохромнго принтера еще подойдет режим -color=no. Он конвертирует файл в режим «оттенков серого» вместо монохромного. Можно и вовсе не указывать режим, тогда будут сохранены свойства исходного файла. У этой утилиты еще много возможностей, она может делать и сигнатуры, и многое другое, но возможностей по гибкому управлению полями у нее нет. Для конвертации в ps, pdf файлов есть утилиты pdftops, pdf2ps. Главное получить ps. Далее готовим книгу к печати:</p>

<ol>
  <li>
    <p>Делаем отдельные сигнатуры по две страницы на листе и по девять листов в сигнатуре. По сути тетрадки. Есть комплект утилит impose+ (impose, psbl, bboxx), они предназначены для этого и намного превосходят psbook, psnup в чистом виде, которые они используют. В частности в отличие от psnup (с которой я промучился довольно долго) эти утилиты умеют гибко управлять полями и автоматически определять размер полезной области листа, в общем, все для вас. Команда <code class="language-plaintext highlighter-rouge">psbl -impose_options "-v -gutter 100 -ym 0 -xm 0" -max_mages 36 ./in.ps</code>. Тут gutter - это поле между страницами. ym — это верхнее поле вдоль длинной стороны листа. xm — поля вдоль короткой стороны листа (именно листы А4, а не страницы, они А5 и их две на листе).  Все размеры полей при необходимости могут быть отрицательными (если поля по умолчанию слишком велики). max_mages — это  число страниц в сигнатуре, это число поделить на 4 и будет число листов, которые надо будет сгибать.</p>
  </li>
  <li>
    <p>У утилиты psbl есть небольшой, но неприятный недостаток. Подготавливая сигнатуры она разделяет ps файл на отдельные файлы сигнатур. Это сделано из лучших побуждений, но неудобно, при печати без двустороннего принтера, это доставляет много неприятностей так как надо отдельные файлы открыть, распечатать сначала четную сторону, потом нечетную в обратном порядке, а этих файлов 15-20 штук на книгу. Применим команду <code class="language-plaintext highlighter-rouge">psmerge -oout-all.ps `ls -mv in.ps.*.imposed|tr "\n" " "|tr "," " "` </code>. Обратите внимание на кавычки <code class="language-plaintext highlighter-rouge">`</code> это важно, кавычка та, что на кнопке клавиатуры с буквой «ё». Команды: ls выводит список файлов, разделенных запятой, tr, заменяет запятые и переносы строк на пробелы, а psmerge берет получившийся список имен файлов, разделенных пробелами и объединяет в один. Эта команда объединит все файлы сигнатур вида <code class="language-plaintext highlighter-rouge">in.ps.*.imposed</code> в один, звездочка это значит «любые символы», там номера страниц будут, но шаблон имени может быть и другим. Причем сигнатуры будут составлены в правильном, числовом-постраничном порядке. Кстати, для сборки отдельных статей в книгу используется эта же утилита.</p>
  </li>
  <li>
    <p>Для того что бы сделать печать удобной надо воспользоваться еще одной утилитой, psselect. Она сохраняет нужные страницы из исходного файла в целевой, например только нечетные или только четные в обратном порядке и пр. Команда для сохранения нечетных страниц выглядит так <code class="language-plaintext highlighter-rouge">psselect -o ./in.ps ./out-odd.ps</code>. Команда для сохранения четных страниц в обратном порядке выглядит так <code class="language-plaintext highlighter-rouge">psselect -e -r ./in.ps ./out-reven.ps</code>. После этого этапа у нас есть два файла, подготовленных для печати в любой программе просмотра ps, без всяких проблем.</p>
  </li>
  <li>
    <p>Печатать надо особым образом. Сначала нечетные страницы из файла out-odd.ps. Потом вынуть пачку листов из принтера, у меня она сверху лежит в hp p1102, обычном домашнем принтере, не меняя ориентации пачки положить их в лоток с бумагой (то есть просто сверху вниз). Если вы взялись сбивать листы поровнее, вытащили их и забыли как они лежали, то надо взять листы, повернуть картинкой к себе и ориентровать ее так же как вы видите на экране открытую книгу, которую печатаете, потом надо положить эту картинку вниз на себя как будто крышку ноутбука положили и воткнуть в принтер. Я так пишу потому, что при печати иногда так запаришься все проделывать, что лучше иметь конкретный метод. Далее печатать четные страницы из файла out-reven.ps. При этом они уже в обратном порядке, это надо потому, что первая страница ранее у нас распечаталась последней. Бумага у нас тонкая, так что я беру один лист, поднимаю слегка и остальную пачку под ним придавливаю пальцем, что бы принтер несколько листов сразу не съел. Это неудобно, но не долго и потом не придется выкидывать пятьдесят листов не допечатанного текста или возится с его допечатыванием.</p>
  </li>
</ol>

<p>Пример выполнения команд по созданию заготовки для печати, «от и до» приведен ниже. Мне надо было из книги выделить страницы с 1 по 31, так что там в начале есть команда psselect, лишняя:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     <span class="nv">$djvups</span> <span class="nt">-verbose</span> ./1.djvu ./1.ps
    <span class="o">[</span><span class="c">##################################################]</span>
    <span class="nv">$ </span>psselect <span class="nt">-p1-31</span> ./1.ps ./2.ps
    <span class="o">[</span>1] <span class="o">[</span>2] <span class="o">[</span>3] <span class="o">[</span>4] <span class="o">[</span>5] <span class="o">[</span>6] <span class="o">[</span>7] <span class="o">[</span>8] <span class="o">[</span>9] <span class="o">[</span>10] <span class="o">[</span>11] <span class="o">[</span>12] <span class="o">[</span>13] <span class="o">[</span>14] <span class="o">[</span>15] <span class="o">[</span>16] <span class="o">[</span>17]
    <span class="o">[</span>18] <span class="o">[</span>19] <span class="o">[</span>20] <span class="o">[</span>21] <span class="o">[</span>22] <span class="o">[</span>23] <span class="o">[</span>24] <span class="o">[</span>25] <span class="o">[</span>26] <span class="o">[</span>27] <span class="o">[</span>28] <span class="o">[</span>29] <span class="o">[</span>30] <span class="o">[</span>31]
    Wrote 31 pages, 25905313 bytes
    <span class="nv">$ </span>psbl <span class="nt">-impose_options</span> <span class="s2">"-gutter -30 -ym -30 -xm -10"</span> <span class="nt">-max_mages</span> 36 ./2.ps
    num_signatures <span class="o">=</span> 1
    q_per_signature <span class="o">=</span> 8
    psselect 1-31 <span class="s1">'./2.ps'</span> | fixtd <span class="nt">-tumble</span> | psbook <span class="o">&gt;</span> <span class="s1">'./2.ps.1-31'</span>
    <span class="o">[</span>1] <span class="o">[</span>2] <span class="o">[</span>3] <span class="o">[</span>4] <span class="o">[</span>5] <span class="o">[</span>6] <span class="o">[</span>7] <span class="o">[</span>8] <span class="o">[</span>9] <span class="o">[</span>10] <span class="o">[</span>11] <span class="o">[</span>12] <span class="o">[</span>13] <span class="o">[</span>14] <span class="o">[</span>15] <span class="o">[</span>16] <span class="o">[</span>17]
    <span class="o">[</span>18] <span class="o">[</span>19] <span class="o">[</span>20] <span class="o">[</span>21] <span class="o">[</span>22] <span class="o">[</span>23] <span class="o">[</span>24] <span class="o">[</span>25] <span class="o">[</span>26] <span class="o">[</span>27] <span class="o">[</span>28] <span class="o">[</span>29] <span class="o">[</span>30] <span class="o">[</span>31]
    Wrote 31 pages, 25905313 bytes
    <span class="o">[</span><span class="k">*</span><span class="o">]</span> <span class="o">[</span>1] <span class="o">[</span>2] <span class="o">[</span>31] <span class="o">[</span>30] <span class="o">[</span>3] <span class="o">[</span>4] <span class="o">[</span>29] <span class="o">[</span>28] <span class="o">[</span>5] <span class="o">[</span>6] <span class="o">[</span>27] <span class="o">[</span>26] <span class="o">[</span>7] <span class="o">[</span>8] <span class="o">[</span>25] <span class="o">[</span>24]
    <span class="o">[</span>9] <span class="o">[</span>10] <span class="o">[</span>23] <span class="o">[</span>22] <span class="o">[</span>11] <span class="o">[</span>12] <span class="o">[</span>21] <span class="o">[</span>20] <span class="o">[</span>13] <span class="o">[</span>14] <span class="o">[</span>19] <span class="o">[</span>18] <span class="o">[</span>15] <span class="o">[</span>16] <span class="o">[</span>17]
    Wrote 32 pages, 25905485 bytes
    impose <span class="nt">-gutter</span> <span class="nt">-30</span> <span class="nt">-ym</span> <span class="nt">-30</span> <span class="nt">-xm</span> <span class="nt">-10</span>  <span class="s1">'./2.ps.1-31'</span>
    Done!
    <span class="nv">$ </span>psselect <span class="nt">-e</span> <span class="nt">-r</span> ./2.ps.1-31.imposed ./2-er.ps
    <span class="o">[</span>16] <span class="o">[</span>14] <span class="o">[</span>12] <span class="o">[</span>10] <span class="o">[</span>8] <span class="o">[</span>6] <span class="o">[</span>4] <span class="o">[</span>2] Wrote 8 pages, 12988730 bytes
    <span class="nv">$ </span>psselect <span class="nt">-o</span> ./2.ps.1-31.imposed ./2-odd.ps
    <span class="o">[</span>1] <span class="o">[</span>3] <span class="o">[</span>5] <span class="o">[</span>7] <span class="o">[</span>9] <span class="o">[</span>11] <span class="o">[</span>13] <span class="o">[</span>15] Wrote 8 pages, 12933748 bytes
    <span class="nv">$ </span>
</code></pre></div></div>

<p>Работает этот пример в подавляющем большинстве случаев, я пробовал на нескольких книгах, надо только поля отрегулировать. В самом простом случае, когда поля вас устраивают сразу, можно воспользоваться командами:</p>

<ol>
  <li>
    <p>Вводим команду <code class="language-plaintext highlighter-rouge">psbook -s36 output.ps output1.ps</code>. Это даст нам будущую книгу, состоящую из тетрадок по 36 страниц А5, то есть по 9 листов А4 в тетрадке. Страницы внутри книги будут пересортированы специально для этого и будут добавлены округляющие до четырех пустые страницы так что бы они все были в конце книги.</p>
  </li>
  <li>
    <p>Вводим команду <code class="language-plaintext highlighter-rouge">psnup -l -pa4 2 output1.ps &gt; output2.ps</code>. Это даст нам файл в котором на одном листе снова размещено по две страницы. Параметр -pa4 задает формат выходных листов. Зачастую сканированные книги сохранены в формате а5, что вполне логично, но утилита не всегда нормально это понимает и ей надо указать входной формат явно. Это делается другим параметром: -Pa5. Если все же автоматический масштаб, выбранный утилитой psnup, не подходит и страницы сбились куда то в угол листа (у меня так было в этот раз), надо применить параметр -s1 и тем самым жестко задать нужный масштаб. В прошлый раз подошло -s0.83. Команда будет такая `psnup -l -pa4 2 -s1 output1.ps &gt; output2.ps</p>
  </li>
  <li>
    <p>Далее пункты 3 и 4 предыдущего списка или просто печать четных, и нечетных страниц в обратном порядке из просмотрщика.</p>
  </li>
</ol>

<h3 id="создание-переплета">Создание переплета</h3>

<p>Теперь пойдет  непосредственно создание переплета. Несмотря на объем текста, вся подготовка займет не так и много времени, что видно из вышеприведенного, наглядного примера создания заготовки для печати. Но изучение этого всего и поиск наиболее простых вариантов работы, все это заняло кучу времени и именно поэтому я написал эту памятку. По сути, «раскачивался» для создания первой книги я почти пол года и наконец то сделал ее, изучив кучу всего, по другому не умею.<br />
Дополнительно можете глянуть книгу «Переплет и реставрация книг Ю. П. Ирошников, И. Г. Ирошникова» или <a href="https://habr.com/ru/articles/96684/">статью</a>, можно там найти еще картинки, более качественные чем с моего телефона. Далее по пунктам. Сначала надо согнуть тетради пополам. Берем по девять листов (в данном случае) и сгибаем их в отдельные тетради. Потом можно все тетради сложить в пачку и в прессе сжать что бы сгиб был стойкий. Либо можно проводить по сгибу ложкой.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/03.jpg" alt="" /></p>

<p>Отмечаем на линейке положения тесемок/дырок и проделываем отверстия шилом или толстой иглой под нитки в тетрадях по линейке. Еще иногда делают по другому. Все тетради зажимают под пресс, а потом делают тонкий пропил (можно и ножом прорезать) там где надо в каждой тетради сделать по отверстию. Но это некрасиво, потом внутри книги отверстия будет видно. Если сделать иголкой, не видно ничего. Тут я «обходиться» не стал так как на края листов смотришь во время чтения все время.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/04.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/05.jpg" alt="" /></p>

<p>Клеим скотчем тесемки на край стола или зажимаем в прессе, после чего пришиваем к ним тетради. Между собой тетради тоже надо сшить. Этот момент особенно непонятен пока не сделаешь первый раз. Сшивая тетради, надо просто зацеплять текущую тетрадь между двумя предыдущими и завязывать нитку одинарным узелком продев иглу в петлю. Когда тетрадей будет пришито несколько их можно уже на скотче не держать, а пришивать к ним следующие прямо с рук. К тесьме тетради именно пришивать не надо надо просто перехлестнуть через них нитку, надеюсь на картинке это видно. Тогда можно будет после пришивания всех тетрадей тесьму подтянуть или сдвинуть внутри ниточных петелек.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/06.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/07.jpg" alt="" /></p>

<p>Проклеиваем книжный блок. До этого момента книжный блок (набор тетрадей) можно переделывать, после уже все жестко, но можно добавлять тетради сверху и снизу. Если это отдельные статьи, то тоже вполне нормально выходит. Клей надо нанести так что бы он немного проник между тетрадями. Для этого я сначала зажимаю сам блок, оставляя его торец висеть выдвигая его на сантиметр, тогда тетради немного расходятся. Потом промазываю клеем и наконец зажимаю в прессе до высыхания.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/08.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/09.jpg" alt="" /></p>

<p>Наклеиваем форзацы. Просто берем край форзаца у сгиба и приклеиваем к первой странице книжного блока.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/10.jpg" alt="" /></p>

<p>После этого можно обточить на диске/обрезать ножом края книжного блока. Возьмите картонку А5 и по ней померьте сколько сточить. Надо так сделать что бы картонка на три-пять миллиметров была больше чем книжный блок, как и в обычных книгах. Но я делаю почти впритык, так как на моем инструменте стачивать очень долго. Или не стачиваю вовсе.<br />
Наклеиваем тесемку-закладку на торец блока. Если хочется.<br />
Наклеиваем бинт или марлю на торец блока что бы армировать его. Без этого можно обойтись.<br />
Наклеиваем капталы, но можно обойтись. Каптал - это широкая стропа с одной стороны более толстая чем с другой, а можно и просто стропу. Нужна больше для декорации, чем для усиления. Стропы продаются в тканях, фиг знает как их называют там, у меня с походов парочка валяется, давно покупал, сейчас вообще обошелся.<br />
Делаем выкройку ткани под переплет. Ткань надо погладить. Углы нужно по диагонали отрезать для складывания, либо заморочиться более сложным способом (который я описывать не стану, есть в книге «Переплет и реставрация книг Ю. П. Ирошников, И. Г. Ирошникова», может там видел, не помню, много прошерстил). Не забудьте, что между обложками надо оставить расстояние под торец книги, на выкройке я его не черчу, но оно там есть.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/11.jpg" alt="" /></p>

<p>Склеиваем в два-три слоя детский картон А5 с канцтоваров что бы получить более жесткий картон. Надо сделать два листа А5. Лучше подержать под прессом, тогда обложка выйдет плоская. Я взял какие то старые картонные бланки и склеил. Делаем тоненькую полоску картона для армирования торца переплета.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/12.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/13.jpg" alt="" /></p>

<p>Собираем книгу. Надо просто вставить книжный блок с обмазанными клеем форзацами, в обмазанную клеем обложку. Причем клея не надо много, под прессом он клеит отлично и в небольших количествах (что мне было непривычно), главное всю поверхность обложки покрыть, а для пущего эффекта и форзацы. Я почти всегда перебарщиваю, кажется мало, как и в этот раз. Вставить в сгибы форзацев листы бумаги что бы блок от клея защитить. Выровнять блок и обложку относительно друг-друга, не парьтесь, просто все воткните и выровняйте, я сначала тесемки там ровнял и вытаскивал, оказалось если вставить оно само на место все встанет, если с умом конечно. Если клея много не лить то вставлять защитные листы не надо будет. Особо проклейте тесемки, нанеся на них клей. Лучше для начала без клея посмотреть как оно подходит друг к другу.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/14.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/15.jpg" alt="" /></p>

<p>Когда высохнет, можно трафаретом на ткань нанести название книги. Тоже можно обойтись.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/16.jpg" alt="" /></p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/17.jpg" alt="" /></p>

<h3 id="заключение">Заключение</h3>

<p>Вот и весь процесс, он занимает где то полдня на второй раз. В выходной вполне можно сделать книжку, такое периодическое хобби, которое успокаивает и расслабляет (если себя не насиловать) и каждый раз когда вы будете брать книгу в руки этот эффект будет повторяться, просто ассоциации будут работать. В конечном итоге не так и много мне книг надо что бы за каждой, месяц охотится по магазинам. Дешевый худ. лит, конечно, так делать, я не стану. Я делаю таким способом книги, к которым не раз обращусь.<br />
В типографии это тоже можно сделать, есть такие услуги, я ими пользовался по «служебной необходимости». Сегодня, печать вам обойдется 1.5р за лист. Переплет около 200р, но переплет будет клееный на скрепках, то есть раскрыть толстую книгу в пятьсот листов и нормально почитать весь день, будет очень неудобно, так печатают вещи потоньше, типа диплома. Плюс ко всему переплет могут не армировать скрепками и он быстро лопнет, а могут и армировать, надо уточнять. Бумага будет офисная, если вы конечно не уточните какая вам нужна и в типографии такую решаться применить на своем оборудовании. Шитый переплет стоит намного дороже.<br />
Помимо снижения стоимости раз в десять, относительно типографии и в двадцать, относительно магазина, самодельный переплет приносит еще и удовольствие от создания отличной вещи, потом книжку в руках держать гораздо приятнее чем это был бы обычный учебник с магазина и это помогает его легче осваивать. Еще осваивать помогает то, что это ваша книга, у меня никогда не было моих книг, даже если я их покупал. На этой я легко делаю пометки на страницах для заметок, задачки решаю (можно даже к концу книги специально добавить страницы для этого), а такого я себе никогда не позволял, как в детстве руки отбили так и смотрел на книги как на музейный экспонат (дорого и редко), и предпочитал пользоваться электронными, прочел сотни книг с монитора. Плюс ко всему раскрыть такой переплет просто удобно, шитые переплеты затем и придумали.<br />
Еще одно преимущество в том, что книгу можно делать самому, вы ее можете сделать из редактируемого файла и оформить как хотите. Можете собрать вообще разрозненный набор статей и документов в книгу, достаточно просто каким то образом это все превратить в pdf. Превращать в pdf, что угодно можно например путем печати на виртуальном pdf принтере, есть такие программы. Если при печати лезут какие то каракули вместо формул, надо найти опцию «печатать как картинку», она обычно в расширенных настройках принтера. Еще можно вместо виртуального принтера воспользоваться функцией «печать в файл» у многих программ, эта функция сделает на выходе либо ps, либо pdf. Объединить получившиеся файлы в один pdf и дальше по схеме. Можно также воспользоваться psmerge для объединения нескольких ps файлов в один.<br />
Тут главное помнить, что это вы делаете, сами, что хотите, то и воротите, я когда инструкцию вижу, мозг вырубается сразу и делаю по инструкции, так что приходится специально помнить об этом. После создания своей первой книги вы станете смотреть на них как на изделие. Развалился переплет какой то, вы его сможете легко взять, что-то придумать и отремонтировать.<br />
Вот например клеенный переплет, который я недавно восстановил. Просверлил его насквозь и ниткой стянул, а небольшие шампуры для сарделек (из сувенирного магазина, не знаю почему их там продавали) использовал как опору для нитки чтобы она страницы не резала. Узелки на концах нитки, проклеил с помощью ПВА. Перед этим всем торцы листков, сложенных в пачку, смазал ПВА и вставил в переплет, это немного, но все же его усилило. В результате имеем целую книгу, а до этого ее в руках держать было сложно, рассыпалась на кучу листов. Причем эти шампуры стали удобной ручкой, за которую книгу можно держать. В общем полезный навык, хотя бы одну книжку сделать самому, вполне можно.</p>

<p><img src="/blog/assets/images/book-for-fifty-rubles/18.jpg" alt="" /></p>]]></content><author><name></name></author><category term="книги" /><category term="книгопечатание" /><category term="своимируками" /><summary type="html"><![CDATA[Лет двадцать назад я опубликовал этот материал, но платформа умерла, сохраню тут этот артефакт далекого прошлого, на мой взгляд до сих пор актуальный. Многим нравится держать книгу в руках, особенно после того как ее сам сделал. При этом на самом деле это довольно дешево, сделать книгу. Плюс, поди найди в магазине нужную книгу. Я вот одну только в Москве нашел, у издателя. Вот и попробуем сделать настоящий шитый переплет, даже намного лучше типичного дешевого клеенного, сами.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/book-for-fifty-rubles/cover.gif" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/book-for-fifty-rubles/cover.gif" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Личное облако на Proxmox: нейронки, LLM и embedding</title><link href="https://3draven.github.io/blog/2025/11/17/proxmox-llm-embedding.html" rel="alternate" type="text/html" title="Личное облако на Proxmox: нейронки, LLM и embedding" /><published>2025-11-17T21:58:46+00:00</published><updated>2025-11-17T21:58:46+00:00</updated><id>https://3draven.github.io/blog/2025/11/17/proxmox-llm-embedding</id><content type="html" xml:base="https://3draven.github.io/blog/2025/11/17/proxmox-llm-embedding.html"><![CDATA[<p>В прошлой <a href="/blog/2025/11/03/proxmox-network.html">статье</a> я описывал как построить сетевую часть самодержавного сервера, пора принести в него что-то отдаленно разумное. Это руководство описывает весь процесс: от подготовки хоста Proxmox и настройки LXC-контейнера до поиска, конвертации и запуска embedding-моделей (на примере <code class="language-plaintext highlighter-rouge">BAAI/bge-large-en-v1.5</code>) с использованием встроенного Intel iGPU для работы модели. Но будет легко запустить не одну модель или полноценного чатбота на этой основе. Главное, что будет ясно как использовать даже простое имеющееся железо домашнего сервера для этого.</p>

<hr />

<p>Небольшое отступление по поводу того, что у нас получилось после прошлой статьи:</p>

<ol>
  <li>
    <p>Есть Asus NUC у которого процессор двенадцатого поколения Intel и Intel Xe графикой, NPU и 12 ядрами CPU. Встройка работает с ОЗУ, а значит медленно, но верно сможет работать даже с приличного размера моделями, но все проверять надо, пока, первый заход.</p>
  </li>
  <li>
    <p>Сетевая инфраструктура, которая позволяет нам поднимать в локальной сети за мостом services, сервисы, которые мы можем легко выставить в интернет через Caddy реверс-прокси.</p>
  </li>
  <li>
    <p>Лично у меня на этот сервер переехал мой инстанс <a href="https://mtdn.anyqn.com/home">Mastodon</a>. Содержать его у хостера было не дешево.</p>
  </li>
  <li>
    <p>Помимо прочего я распилил изолированный мост services на набор отдельных VLAN для групп сервисов что бы изолировать их дополнительно, теперь OPNSense настоящий шлюз между этими сетями. Включил на OPNSense такую штуку как Suricata, которая показывает когда сервисы сканируют автоботы на предмет открытых уязвимостей, не сказать еще хужей. Если бы самодержавные хостеры знали сколько их сканят, они бы беспокоились о безопасности побольше, но о безопасности в другой раз.</p>
  </li>
</ol>

<p>Чего мы хотим:</p>

<ol>
  <li>
    <p>Счастья.</p>
  </li>
  <li>
    <p>Пробросить Intel GPU из Proxmox хоста внутрь LXC контейнера, а там и внутрь Docker контейнера. Вложенность такая потому, что запуск среды обработки неронных моделей без Docker это довольно сложное мероприятие.</p>
  </li>
  <li>
    <p>Запустить LXC контейнер с OpenVINO models server (OVMS), который будет движком, обеспечивающим embedding или inference, зависит от наших потребностей. Движек от Intel для их железок, очень оптимизированный и много, что может. Как запуск большого количества моделей, так и маскировку под разные API доступа.</p>
  </li>
  <li>
    <p>В принципе хотим еще и WebUI на это все натянуть, но это за рамками статьи потому, что мне это не нужно и сделаь это просто, так как OVMS поддерживает самые разные распространенные API, например от OpenAI, с которыми работает любая WebUI. Это в его документации описано довольно ясно.</p>
  </li>
  <li>
    <p>Получить возможность сделать curl запрос к нейронке и получить embedding, что мне в данном случае и нужно, так как хочется построить свой RAG и давать к нему доступ всяким нейронкам в виде MCP, если вы понимаете о чем я.</p>
  </li>
</ol>

<p>Штош, хватит отступлений, поехали. Напомню только, что если что-то не ясно, спросите нейронку, они отлично пояснят.</p>

<h3 id="краткое-содержание-следующих-серий">Краткое содержание следующих серий</h3>

<p>Вот <a href="https://docs.openvino.ai/2025/documentation/compatibility-and-support/supported-models.html">тут</a> и <a href="https://huggingface.co/OpenVINO/models">тут</a> можно найти море готовых моделей. После чего вот так их запустить:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ovms:~/ovms<span class="nv">$ </span>tree <span class="nb">.</span>
<span class="nb">.</span>
└── models

2 directories, 0 files
i3draven@ovms:~/ovms<span class="nv">$ </span>docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
i3draven@ovms:~/ovms<span class="nv">$ </span>docker run <span class="nt">--rm</span> <span class="nt">-u</span> 1000   <span class="nt">-v</span> ~/ovms/models:/models:rw   openvino/model_server:latest-gpu   <span class="nt">--pull</span>   <span class="nt">--model_name</span> llm   <span class="nt">--source_model</span> OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov <span class="nt">--target_device</span> GPU <span class="nt">--task</span> text_generation <span class="nt">--model_repository_path</span> /models
remote: Enumerating objects: 19, <span class="k">done</span><span class="nb">.</span>
remote: Counting objects: 100% <span class="o">(</span>16/16<span class="o">)</span>, <span class="k">done</span><span class="nb">.</span>
remote: Compressing objects: 100% <span class="o">(</span>16/16<span class="o">)</span>, <span class="k">done</span><span class="nb">.</span>
remote: Total 19 <span class="o">(</span>delta 0<span class="o">)</span>, reused 0 <span class="o">(</span>delta 0<span class="o">)</span>, pack-reused 3 <span class="o">(</span>from 1<span class="o">)</span>
Receiving objects: 100% <span class="o">(</span>19/19<span class="o">)</span>, 17.19 KiB | 0.00 B/s, <span class="k">done</span><span class="nb">.</span>
Checking out files:   0% <span class="o">(</span>0/14<span class="o">)</span>
Downloading lfs size: 2.09 MiB file: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov/openvino_detokenizer.bin
Progress: <span class="o">[</span><span class="c">##################################################] 100.00% [2.09 MiB/s]  </span>
Checking out files:  42% <span class="o">(</span>6/14<span class="o">)</span> 
Downloading lfs size: 1.30 GiB file: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov/openvino_model.bin
Progress: <span class="o">[</span><span class="c">##################################################] 100.00% [66.60 MiB/s] </span>
Checking out files:  57% <span class="o">(</span>8/14<span class="o">)</span> 
Downloading lfs size: 5.33 MiB file: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov/openvino_tokenizer.bin
Progress: <span class="o">[</span><span class="c">##################################################] 100.00% [5.33 MiB/s] </span>
Checking out files:  71% <span class="o">(</span>10/14<span class="o">)</span>
Downloading lfs size: 10.89 MiB file: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov/tokenizer.json
Progress: <span class="o">[</span><span class="c">##################################################] 100.00% [10.89 MiB/s] </span>
Model: OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov downloaded to: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov
Graph: graph.pbtxt created <span class="k">in</span>: /models/OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov
i3draven@ovms:~/ovms<span class="nv">$ </span>docker run <span class="nt">--rm</span> <span class="nt">-u</span> 1000   <span class="nt">-v</span> ~/ovms/models:/models:rw   openvino/model_server:latest   <span class="nt">--list_models</span> <span class="nt">--model_repository_path</span> /models
OpenVINO/DeepSeek-R1-Distill-Qwen-1.5B-int4-ov
i3draven@ovms:~/ovms<span class="nv">$ </span>docker run <span class="nt">--rm</span> <span class="nt">-u</span> 1000   <span class="nt">-v</span> ~/ovms/models:/models:rw   openvino/model_server:latest   <span class="nt">--add_to_config</span> /models   <span class="nt">--model_name</span> DeepSeek-R1-Distill-Qwen-1.5B-int4-ov <span class="nt">--model_repository_path</span> /models/OpenVINO
Config updated: /models/config.json
i3draven@ovms:~/ovms<span class="nv">$ </span>docker run <span class="nt">-d</span> <span class="nt">--rm</span>   <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span>   <span class="nt">--device</span> /dev/dri:/dev/dri   <span class="nt">--group-add</span> <span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s2">"%g"</span> /dev/dri/render<span class="k">*</span> | <span class="nb">head</span> <span class="nt">-n</span> 1<span class="si">)</span>   <span class="nt">-v</span> ~/ovms/models:/models:ro   <span class="nt">-p</span> 9000:9000 <span class="nt">-p</span> 8000:8000   openvino/model_server:latest-gpu   <span class="nt">--config_path</span> /models/config.json   <span class="nt">--port</span> 9000   <span class="nt">--rest_port</span> 8000
c4cd80757c4c8abe9f85a9c08806f1691b2a123e7e0e582ae37a968f89c86bc7
i3draven@ovms:~/ovms<span class="nv">$ </span>docker ps
CONTAINER ID   IMAGE                              COMMAND                  CREATED         STATUS         PORTS                                                                                      NAMES
c4cd80757c4c   openvino/model_server:latest-gpu   <span class="s2">"/ovms/bin/ovms --co…"</span>   3 seconds ago   Up 3 seconds   0.0.0.0:8000-&gt;8000/tcp, <span class="o">[</span>::]:8000-&gt;8000/tcp, 0.0.0.0:9000-&gt;9000/tcp, <span class="o">[</span>::]:9000-&gt;9000/tcp   keen_diffie
i3draven@ovms:~/ovms<span class="nv">$ </span>docker logs c4cd80757c4c
<span class="o">[</span>2025-11-25 19:52:43.325][1][serving][info][server.cpp:91] OpenVINO Model Server 2025.3.0.6e2e910de
<span class="o">[</span>2025-11-25 19:52:43.325][1][serving][info][server.cpp:92] OpenVINO backend 2025.3.0.dev20250826
<span class="o">[</span>2025-11-25 19:52:43.325][1][serving][info][pythoninterpretermodule.cpp:37] PythonInterpreterModule starting
<span class="o">[</span>2025-11-25 19:52:43.381][1][serving][info][pythoninterpretermodule.cpp:50] PythonInterpreterModule started
<span class="o">[</span>2025-11-25 19:52:43.423][1][modelmanager][info][modelmanager.cpp:156] Available devices <span class="k">for </span>Open VINO: CPU, GPU
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][capimodule.cpp:40] C-APIModule starting
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][capimodule.cpp:42] C-APIModule started
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][grpcservermodule.cpp:102] GRPCServerModule starting
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][grpcservermodule.cpp:179] GRPCServerModule started
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][grpcservermodule.cpp:180] Started gRPC server on port 9000
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][httpservermodule.cpp:35] HTTPServerModule starting
<span class="o">[</span>2025-11-25 19:52:43.424][1][serving][info][httpservermodule.cpp:39] Will start 16 REST workers
<span class="o">[</span>2025-11-25 19:52:43.475][1][serving][info][drogon_http_server.cpp:158] REST server listening on port 8000 with 16 unary threads and 16 streaming threads
<span class="o">[</span>2025-11-25 19:52:43.475][1][serving][info][httpservermodule.cpp:61] HTTPServerModule started
<span class="o">[</span>2025-11-25 19:52:43.475][1][serving][info][httpservermodule.cpp:62] Started REST server at 0.0.0.0:8000
<span class="o">[</span>2025-11-25 19:52:43.475][1][serving][info][servablemanagermodule.cpp:51] ServableManagerModule starting
<span class="o">[</span>2025-11-25 19:52:43.477][1][serving][info][mediapipegraphdefinition.cpp:421] MediapipeGraphDefinition initializing graph nodes
<span class="o">[</span>2025-11-25 19:52:43.477][1][modelmanager][info][servable_initializer.cpp:463] Initializing Language Model Continuous Batching servable
<span class="o">[</span>2025-11-25 19:52:50.779][1][modelmanager][info][mediapipegraphdefinition.cpp:182] Mediapipe: DeepSeek-R1-Distill-Qwen-1.5B-int4-ov inputs: 
name: input<span class="p">;</span> mapping: <span class="p">;</span> shape: <span class="o">()</span><span class="p">;</span> precision: UNDEFINED<span class="p">;</span> layout: ...
<span class="o">[</span>2025-11-25 19:52:50.779][1][modelmanager][info][mediapipegraphdefinition.cpp:183] Mediapipe: DeepSeek-R1-Distill-Qwen-1.5B-int4-ov outputs: 
name: output<span class="p">;</span> mapping: <span class="p">;</span> shape: <span class="o">()</span><span class="p">;</span> precision: UNDEFINED<span class="p">;</span> layout: ...
<span class="o">[</span>2025-11-25 19:52:50.779][1][modelmanager][info][mediapipegraphdefinition.cpp:184] Mediapipe: DeepSeek-R1-Distill-Qwen-1.5B-int4-ov kfs pass through: <span class="nb">false</span>
<span class="o">[</span>2025-11-25 19:52:50.779][1][modelmanager][info][pipelinedefinitionstatus.hpp:59] Mediapipe: DeepSeek-R1-Distill-Qwen-1.5B-int4-ov state changed to: AVAILABLE after handling: ValidationPassedEvent: 
<span class="o">[</span>2025-11-25 19:52:50.779][1][serving][info][servablemanagermodule.cpp:55] ServableManagerModule started
<span class="o">[</span>2025-11-25 19:52:50.779][130][llm_executor][info][llm_executor.hpp:90] All requests: 0<span class="p">;</span> Scheduled requests: 0<span class="p">;</span>
<span class="o">[</span>2025-11-25 19:52:50.779][131][modelmanager][info][modelmanager.cpp:1201] Started model manager thread
<span class="o">[</span>2025-11-25 19:52:50.779][132][modelmanager][info][modelmanager.cpp:1220] Started cleaner thread
i3draven@ovms:~/ovms<span class="nv">$ </span>curl http://localhost:8000/v3/chat/completions   <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span>   <span class="nt">-d</span> <span class="s1">'{"model":"DeepSeek-R1-Distill-Qwen-1.5B-int4-ov","messages":[{"role": "system", "content": "Ты полезный ассистент отвечающий на русском языке."},{"role":"user","content":"Hello"}],"max_tokens":50}'</span> | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   744  100   505  100   239    103     48  0:00:04  0:00:04 <span class="nt">--</span>:--:--   136
<span class="o">{</span>
  <span class="s2">"choices"</span>: <span class="o">[</span>
    <span class="o">{</span>
      <span class="s2">"finish_reason"</span>: <span class="s2">"length"</span>,
      <span class="s2">"index"</span>: 0,
      <span class="s2">"logprobs"</span>: null,
      <span class="s2">"message"</span>: <span class="o">{</span>
        <span class="s2">"content"</span>: <span class="s2">"Okay, so I just received a message from someone asking me to act as an assistant. They said, </span><span class="se">\"</span><span class="s2">Ты полезный ассистент отвечающий на русском языке.</span><span class="se">\"</span><span class="s2"> Translating that, it's"</span>,
        <span class="s2">"role"</span>: <span class="s2">"assistant"</span>,
        <span class="s2">"tool_calls"</span>: <span class="o">[]</span>
      <span class="o">}</span>
    <span class="o">}</span>
  <span class="o">]</span>,
  <span class="s2">"created"</span>: 1764100387,
  <span class="s2">"model"</span>: <span class="s2">"DeepSeek-R1-Distill-Qwen-1.5B-int4-ov"</span>,
  <span class="s2">"object"</span>: <span class="s2">"chat.completion"</span>,
  <span class="s2">"usage"</span>: <span class="o">{</span>
    <span class="s2">"prompt_tokens"</span>: 27,
    <span class="s2">"completion_tokens"</span>: 50,
    <span class="s2">"total_tokens"</span>: 77
  <span class="o">}</span>
<span class="o">}</span>
i3draven@ovms:~/ovms<span class="nv">$ </span>


</code></pre></div></div>

<p>Готовый скрипт для скачивания и настройки моделей</p>

<p>Скрипт</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="c"># Цвета для вывода</span>
<span class="nv">GREEN</span><span class="o">=</span><span class="s1">'\033[0;32m'</span>
<span class="nv">BLUE</span><span class="o">=</span><span class="s1">'\033[0;34m'</span>
<span class="nv">RED</span><span class="o">=</span><span class="s1">'\033[0;31m'</span>
<span class="nv">YELLOW</span><span class="o">=</span><span class="s1">'\033[1;33m'</span>
<span class="nv">NC</span><span class="o">=</span><span class="s1">'\033[0m'</span> <span class="c"># No Color</span>

<span class="c"># Запрос директории для моделей с значением по умолчанию</span>
<span class="nv">DEFAULT_DIR</span><span class="o">=</span><span class="s2">"./models"</span>
<span class="nb">read</span> <span class="nt">-p</span> <span class="s2">"Укажите директорию для сохранения моделей [</span><span class="k">${</span><span class="nv">DEFAULT_DIR</span><span class="k">}</span><span class="s2">]: "</span> MODEL_DIR
<span class="nv">MODEL_DIR</span><span class="o">=</span><span class="k">${</span><span class="nv">MODEL_DIR</span><span class="k">:-</span><span class="nv">$DEFAULT_DIR</span><span class="k">}</span>

<span class="c"># Создаем директорию если не существует</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$MODEL_DIR</span><span class="s2">"</span>

<span class="c"># Получаем абсолютный путь</span>
<span class="nv">MODEL_DIR</span><span class="o">=</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="nv">$MODEL_DIR</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="si">)</span>

<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BLUE</span><span class="k">}</span><span class="s2">Используется директория: </span><span class="nv">$MODEL_DIR</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Запрос имени модели с примером по умолчанию</span>
<span class="nv">DEFAULT_MODEL</span><span class="o">=</span><span class="s2">"OpenVINO/Qwen2.5-7B-Instruct-int8-ov"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">YELLOW</span><span class="k">}</span><span class="s2">Пример модели: https://huggingface.co/OpenVINO/Qwen2.5-7B-Instruct-int8-ov</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">read</span> <span class="nt">-p</span> <span class="s2">"Укажите имя модели [</span><span class="k">${</span><span class="nv">DEFAULT_MODEL</span><span class="k">}</span><span class="s2">]: "</span> SOURCE_MODEL
<span class="nv">SOURCE_MODEL</span><span class="o">=</span><span class="k">${</span><span class="nv">SOURCE_MODEL</span><span class="k">:-</span><span class="nv">$DEFAULT_MODEL</span><span class="k">}</span>

<span class="c"># Извлекаем организацию и имя модели</span>
<span class="nv">MODEL_ORG</span><span class="o">=</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$SOURCE_MODEL</span><span class="s2">"</span><span class="si">)</span>
<span class="nv">MODEL_NAME</span><span class="o">=</span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$SOURCE_MODEL</span><span class="s2">"</span><span class="si">)</span>

<span class="c"># Выбор типа задачи (TASK)</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">YELLOW</span><span class="k">}</span><span class="s2">Выберите тип задачи:</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">PS3</span><span class="o">=</span><span class="s2">"Введите номер или название задачи: "</span>
<span class="nv">TASK_OPTIONS</span><span class="o">=(</span><span class="s2">"text_generation"</span> <span class="s2">"embeddings"</span> <span class="s2">"rerank"</span> <span class="s2">"image_generation"</span><span class="o">)</span>
<span class="nv">TASK</span><span class="o">=</span><span class="s2">""</span>

<span class="k">while</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$TASK</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">do
    select </span>task_choice <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TASK_OPTIONS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
        if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$task_choice</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
            </span><span class="nv">TASK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$task_choice</span><span class="s2">"</span>
            <span class="nb">break
        </span><span class="k">elif</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$REPLY</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
            <span class="c"># Проверка, если пользователь ввел текстом</span>
            <span class="k">for </span>option <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TASK_OPTIONS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
                if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$REPLY</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"</span><span class="nv">$option</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
                    </span><span class="nv">TASK</span><span class="o">=</span><span class="s2">"</span><span class="nv">$option</span><span class="s2">"</span>
                    <span class="nb">break </span>2
                <span class="k">fi
            done
            if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$TASK</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
                </span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">RED</span><span class="k">}</span><span class="s2">Неверный выбор. Попробуйте снова.</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
            <span class="k">fi
        fi
        </span><span class="nb">break
    </span><span class="k">done
done

</span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BLUE</span><span class="k">}</span><span class="s2">Выбрана задача: </span><span class="nv">$TASK</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Выбор целевого устройства (TARGET_DEVICE)</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">YELLOW</span><span class="k">}</span><span class="s2">Выберите целевое устройство:</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">PS3</span><span class="o">=</span><span class="s2">"Введите номер или название устройства: "</span>
<span class="nv">DEVICE_OPTIONS</span><span class="o">=(</span><span class="s2">"CPU"</span> <span class="s2">"GPU"</span> <span class="s2">"MULTI"</span> <span class="s2">"HETERO"</span><span class="o">)</span>
<span class="nv">TARGET_DEVICE</span><span class="o">=</span><span class="s2">""</span>

<span class="k">while</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$TARGET_DEVICE</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">do
    select </span>device_choice <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">DEVICE_OPTIONS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
        if</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$device_choice</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
            </span><span class="nv">TARGET_DEVICE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$device_choice</span><span class="s2">"</span>
            <span class="nb">break
        </span><span class="k">elif</span> <span class="o">[[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$REPLY</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
            <span class="c"># Проверка, если пользователь ввел текстом</span>
            <span class="k">for </span>option <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">DEVICE_OPTIONS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
                if</span> <span class="o">[[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">REPLY</span><span class="p">^^</span><span class="k">}</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"</span><span class="nv">$option</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
                    </span><span class="nv">TARGET_DEVICE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$option</span><span class="s2">"</span>
                    <span class="nb">break </span>2
                <span class="k">fi
            done
            if</span> <span class="o">[[</span> <span class="nt">-z</span> <span class="s2">"</span><span class="nv">$TARGET_DEVICE</span><span class="s2">"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
                </span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">RED</span><span class="k">}</span><span class="s2">Неверный выбор. Попробуйте снова.</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
            <span class="k">fi
        fi
        </span><span class="nb">break
    </span><span class="k">done
done

</span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BLUE</span><span class="k">}</span><span class="s2">Выбрано устройство: </span><span class="nv">$TARGET_DEVICE</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># Определяем версию образа в зависимости от устройства</span>
<span class="k">if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$TARGET_DEVICE</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"GPU"</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">DOCKER_IMAGE</span><span class="o">=</span><span class="s2">"openvino/model_server:latest-gpu"</span>
<span class="k">else
    </span><span class="nv">DOCKER_IMAGE</span><span class="o">=</span><span class="s2">"openvino/model_server:latest"</span>
<span class="k">fi

</span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">BLUE</span><span class="k">}</span><span class="s2">Конфигурация:</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Модель: </span><span class="nv">$SOURCE_MODEL</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Организация: </span><span class="nv">$MODEL_ORG</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Имя: </span><span class="nv">$MODEL_NAME</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Задача: </span><span class="nv">$TASK</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Устройство: </span><span class="nv">$TARGET_DEVICE</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"  Docker образ: </span><span class="nv">$DOCKER_IMAGE</span><span class="s2">"</span>


<span class="c"># Шаг 1: Скачиваем модель</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">GREEN</span><span class="k">}</span><span class="s2">[1/3] Загрузка модели </span><span class="nv">$SOURCE_MODEL</span><span class="s2">...</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
docker run <span class="nt">--rm</span> <span class="nt">-u</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span> <span class="se">\</span>
  <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$MODEL_DIR</span><span class="s2">"</span>:/models:rw <span class="se">\</span>
  <span class="s2">"</span><span class="nv">$DOCKER_IMAGE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--pull</span> <span class="se">\</span>
  <span class="nt">--source_model</span> <span class="s2">"</span><span class="nv">$SOURCE_MODEL</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--target_device</span> <span class="s2">"</span><span class="nv">$TARGET_DEVICE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--task</span> <span class="s2">"</span><span class="nv">$TASK</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--model_repository_path</span> /models

<span class="k">if</span> <span class="o">[</span> <span class="nv">$?</span> <span class="nt">-ne</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">RED</span><span class="k">}</span><span class="s2">Ошибка при загрузке модели!</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
    <span class="nb">exit </span>1
<span class="k">fi</span>

<span class="c"># Шаг 2: Добавляем модель в конфиг</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">GREEN</span><span class="k">}</span><span class="s2">[2/3] Добавление модели в config.json...</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
docker run <span class="nt">--rm</span> <span class="nt">-u</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span> <span class="se">\</span>
  <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$MODEL_DIR</span><span class="s2">"</span>:/models:rw <span class="se">\</span>
  openvino/model_server:latest <span class="se">\</span>
  <span class="nt">--add_to_config</span> /models <span class="se">\</span>
  <span class="nt">--model_name</span> <span class="s2">"</span><span class="nv">$MODEL_NAME</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--model_repository_path</span> /models/<span class="s2">"</span><span class="nv">$MODEL_ORG</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">[</span> <span class="nv">$?</span> <span class="nt">-ne</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then

    </span><span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">RED</span><span class="k">}</span><span class="s2">Ошибка при добавлении модели в конфиг!</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
    <span class="nb">exit </span>1
<span class="k">fi</span>

<span class="c"># Шаг 3: Выводим список моделей</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="k">${</span><span class="nv">GREEN</span><span class="k">}</span><span class="s2">[3/3] Список доступных моделей:</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
docker run <span class="nt">--rm</span> <span class="nt">-u</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span> <span class="se">\</span>
  <span class="nt">-v</span> <span class="s2">"</span><span class="nv">$MODEL_DIR</span><span class="s2">"</span>:/models:rw <span class="se">\</span>
  openvino/model_server:latest <span class="se">\</span>
  <span class="nt">--list_models</span> <span class="se">\</span>
  <span class="nt">--model_repository_path</span> /models

<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"</span><span class="se">\n</span><span class="k">${</span><span class="nv">BLUE</span><span class="k">}</span><span class="s2">✓ Готово! Модель </span><span class="nv">$MODEL_NAME</span><span class="s2"> загружена и добавлена в конфигурацию.</span><span class="k">${</span><span class="nv">NC</span><span class="k">}</span><span class="s2">"</span>
</code></pre></div></div>

<p>Файл с переменными окружения, ниже в статье описано, что там такое написано и как достать.</p>

<p>.env</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ovms:~/ovms<span class="nv">$ </span><span class="nb">cat</span> ./.env 
<span class="nv">RENDER_GID</span><span class="o">=</span>993
<span class="nv">YOUR_UID</span><span class="o">=</span>1000
i3draven@ovms:~/ovms<span class="nv">$ </span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">docker-compose.yml</code> для запуска сервера, причем со всеми скачанными моделями сразу сработает. Скрипт можно много раз запустить.</p>

<p>docker-compose.yml</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ovms:~/ovms<span class="nv">$ </span><span class="nb">cat</span> ./docker-compose.yml 
services:
  ovms-genai:
    image: openvino/model_server:latest-gpu
    container_name: ovms-genai
    user: <span class="s2">"</span><span class="k">${</span><span class="nv">YOUR_UID</span><span class="k">}</span><span class="s2">:</span><span class="k">${</span><span class="nv">YOUR_UID</span><span class="k">}</span><span class="s2">"</span>
    devices:
      - /dev/dri:/dev/dri
    group_add:
      - <span class="s2">"</span><span class="k">${</span><span class="nv">RENDER_GID</span><span class="k">}</span><span class="s2">"</span>
    volumes:
      - /home/i3draven/ovms/models:/models:ro
      - /home/i3draven/ovms/models/config.json:/opt/ml/config.json:ro
    ports:
      - <span class="s2">"8000:8000"</span>
      - <span class="s2">"9000:9000"</span>
    <span class="nb">command</span>:
      - <span class="nt">--config_path</span>
      - /opt/ml/config.json
      - <span class="nt">--port</span>
      - <span class="s2">"9000"</span>
      - <span class="nt">--rest_port</span>
      - <span class="s2">"8000"</span>
    <span class="c">#restart: unless-stopped</span>
</code></pre></div></div>

<p>Пример запуска</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ovms:~/ovms<span class="nv">$ </span>curl http://localhost:8000/v3/chat/completions   <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span>   <span class="nt">-d</span> <span class="s1">'{"model":"Qwen2.5-7B-Instruct-int8-ov","messages":[{"role": "system", "content": "Ты полезный ассистент отвечающий на русском языке."},{"role":"user","content":"Hello"}],"max_tokens":250}'</span> | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   590  100   360  100   230     57     36  0:00:06  0:00:06 <span class="nt">--</span>:--:--    89
<span class="o">{</span>
  <span class="s2">"choices"</span>: <span class="o">[</span>
    <span class="o">{</span>
      <span class="s2">"finish_reason"</span>: <span class="s2">"stop"</span>,
      <span class="s2">"index"</span>: 0,
      <span class="s2">"logprobs"</span>: null,
      <span class="s2">"message"</span>: <span class="o">{</span>
        <span class="s2">"content"</span>: <span class="s2">"Здравствуйте! Как я могу вам помочь сегодня?"</span>,
        <span class="s2">"role"</span>: <span class="s2">"assistant"</span>,
        <span class="s2">"tool_calls"</span>: <span class="o">[]</span>
      <span class="o">}</span>
    <span class="o">}</span>
  <span class="o">]</span>,
  <span class="s2">"created"</span>: 1764105373,
  <span class="s2">"model"</span>: <span class="s2">"Qwen2.5-7B-Instruct-int8-ov"</span>,
  <span class="s2">"object"</span>: <span class="s2">"chat.completion"</span>,
  <span class="s2">"usage"</span>: <span class="o">{</span>
    <span class="s2">"prompt_tokens"</span>: 35,
    <span class="s2">"completion_tokens"</span>: 17,
    <span class="s2">"total_tokens"</span>: 52
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Сервер сам все скачает и запустит. Но если интересно понять немного больше о том, что это все такое, то можно прочитать и дальше.</p>

<h3 id="шаг-0-настройка-proxmox-и-проброс-gpu-в-lxc">Шаг 0: Настройка Proxmox и проброс GPU в LXC</h3>

<p>Есть масса статей на эту тему, но как руками отредактировать конфиги LXC контейнеров, на деле все можно сделать в два клика в UI Proxmox, если знать что делать и как.</p>

<h4 id="1-установка-драйверов-intel-на-хост-proxmox">1. Установка драйверов Intel на хост Proxmox</h4>

<p>Обратите внимание, что установка драйверов на хост это действие противоположное пробросу GPU в виртуалки, вполне вероятно это несовместимые подходы в рамках одного хоста. Подробности можно посмотреть <a href="https://www.virtualizationhowto.com/2025/05/how-to-enable-gpu-passthrough-to-lxc-containers-in-proxmox/">тут</a> и <a href="https://blog.kye.dev/proxmox-gpu-passthrough">тут</a></p>

<p>Подключитесь к хосту Proxmox по SSH и выполните следующие команды:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Установка зависимостей</span>
apt-get update <span class="o">&amp;&amp;</span> apt-get <span class="nb">install</span> <span class="nt">-y</span> wget gpg

<span class="c"># Добавление ключа репозитория Intel</span>
wget <span class="nt">-qO</span> - https://repositories.intel.com/gpu/intel-graphics.key | <span class="se">\</span>
  gpg <span class="nt">--yes</span> <span class="nt">--dearmor</span> <span class="nt">--output</span> /usr/share/keyrings/intel-graphics.gpg

<span class="c"># Добавление репозитория Intel GPU, я ставил сразу noble, но вероятно можно bookworm.</span>
<span class="nb">cat</span> <span class="o">&gt;</span> /etc/apt/sources.list.d/intel-gpu.list <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu noble client
</span><span class="no">EOF

</span><span class="c"># Обновление списка пакетов и установка драйверов</span>
apt-get update
apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\</span>
  intel-opencl-icd <span class="se">\</span>
  intel-media-va-driver-non-free <span class="se">\</span>
  ocl-icd-libopencl1 <span class="se">\</span>
  clinfo <span class="se">\</span>
  intel-gpu-tools
</code></pre></div></div>

<p>Проверьте, что драйвер загружен, возможно нужно будет reboot Proxmox хоста:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>clinfo
</code></pre></div></div>

<p>Увидите свой GPU в списке.</p>

<h4 id="2-сбор-необходимой-информации">2. Сбор необходимой информации</h4>

<p><strong>На хосте Proxmox:</strong></p>

<p>Проверьте список устройств GPU, именно их нужно будет потом добавлять, у вас будут свои вероятно.</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pve:~# <span class="nb">ls</span> /dev/dri
by-path  card1  renderD128
</code></pre></div></div>

<p>Узнайте GID группы render (запишите это число)</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pve:~# <span class="nb">stat</span> <span class="nt">-c</span> <span class="s1">'%g'</span> /dev/dri/renderD128
993
</code></pre></div></div>

<p><strong>Внутри LXC контейнера:</strong></p>

<p>Для простоты нам нужен контейнер с ubuntu 24.04, под него вся среда у Intel заточена. При этои мы будем использовать возможности Intel Xe GPU и легко то же самое сделать с CPU или даже обоими. Просто потому, что у меня эта встройка есть на сервере и ее для моих задач достаточно. Но, конечно примерно таким же образом можно настроить и другие GPU, под Nvidia так вообще есть полные подробные гайды. Использовать будем OpenVINO models server, подробности: <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_llm_quickstart.html">тут</a>,<a href="https://github.com/openvinotoolkit/openvino_contrib/tree/master/modules/ollama_openvino">тут</a>,<a href="https://blog.openvino.ai/blog-posts/ollama-integrated-with-openvino-accelerating-deepseek-inference">тут</a>,<a href="https://ollama.com/blog/embedding-models">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_deploying_server_docker.html">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_serving_model.html">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_parameters.html">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_target_devices.html">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_rest_api_embeddings.html">тут</a>,<a href="https://docs.openvino.ai/2025/model-server/ovms_docs_models_repository_graph.html">тут</a></p>

<p>Я привел серию статей включая статьи про ollama что бы было ясно, что можно ollama сделать фронтендом для интеловских поделок, но я не стал, так как у intel есть свой собственный сервер, который поддерживает API доступа по http и нормально работает без дополнительных прослоек. Правда прослойки конечно могут делать что-то полезное типа конвертации моделей, но мне пойдет и так.</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Узнайте UID вашего пользователя внутри LXC (запишите это число)</span>
<span class="nb">id</span> <span class="nt">-u</span>
</code></pre></div></div>

<h4 id="3-настройка-проброса-gpu-через-веб-интерфейс-proxmox">3. Настройка проброса GPU через веб-интерфейс Proxmox</h4>

<ol>
  <li>
    <p>В веб-интерфейсе Proxmox выберите ваш LXC-контейнер</p>
  </li>
  <li>
    <p>Откройте вкладку <strong>«Ресурсы» (Resources)</strong></p>
  </li>
  <li>
    <p>Нажмите <strong>«Добавить» (Add)</strong> → <strong>«Проброс устройства» (Device Passthrough)</strong>. Особо обратите внимание на то, что <code class="language-plaintext highlighter-rouge">Device Passthrough</code> доступен только если вы зашли в WebUI под пользователем root.</p>
  </li>
  <li>
    <p>Добавьте устройство <code class="language-plaintext highlighter-rouge">/dev/dri/renderD128</code>, в расширенных настройках (advanced галка) нужно задать GID от группы render на хосте Proxmox (у меня 993), который мы ранее достали и UID пользователя в LXC контейнере, которому надо дать доступ (у меня 1000, это тот, что записывали). Так же можно указать права доступа, но по умолчанию они заданы и так достаточные, можно не трогать.</p>
  </li>
  <li>
    <p>Повторите для устройства <code class="language-plaintext highlighter-rouge">/dev/dri/card1</code></p>
  </li>
</ol>

<h4 id="4-отключение-apparmor-для-контейнера">4. Отключение AppArmor для контейнера</h4>

<p>Недавний <a href="https://github.com/opencontainers/runc/issues/4968">баг</a> в Proxmox не дает использовать вложенные docker контейнеры внутри LXC, поправим. Но может его исправят, уже патч был когда я писал статью. Так что если не видите ошибок при старте docker контейнера внутри LXC контейнера, то не нужно. На хосте Proxmox выполните:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Замените 100 на ID вашего контейнера</span>
nano /etc/pve/lxc/<span class="k">${</span><span class="nv">lxc_container_id</span><span class="k">}</span>.conf
</code></pre></div></div>

<p>Добавьте в конец файла:</p>

<p>Команды</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lxc.apparmor.profile: unconfined
lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none bind 0 0
</code></pre></div></div>

<p>Так будет примерно выглядеть:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">arch</span>: amd64
cores: 8
dev0: /dev/dri/card1,gid<span class="o">=</span>993,uid<span class="o">=</span>1000
dev1: /dev/dri/renderD128,gid<span class="o">=</span>993,uid<span class="o">=</span>1000
features: <span class="nv">nesting</span><span class="o">=</span>1
<span class="nb">hostname</span>: ubuntu.24.04
memory: 16768
nameserver: 10.0.40.1
net0: <span class="nv">name</span><span class="o">=</span>eth0,bridge<span class="o">=</span>services,hwaddr<span class="o">=</span>BC:24:11:CD:8A:8F,ip<span class="o">=</span>dhcp,tag<span class="o">=</span>40,type<span class="o">=</span>veth
onboot: 1
ostype: ubuntu
rootfs: local-btrfs:107/vm-107-disk-0.raw,mountoptions<span class="o">=</span>discard,size<span class="o">=</span>64G
swap: 0
unprivileged: 1
lxc.apparmor.profile: unconfined
lxc.mount.entry: /dev/null sys/module/apparmor/parameters/enabled none <span class="nb">bind </span>0 0
</code></pre></div></div>

<p>Сохраните файл и перезапустите контейнер через веб-интерфейс или <code class="language-plaintext highlighter-rouge">pct stop ID</code>/<code class="language-plaintext highlighter-rouge">pct start ID</code>. Обратите внимание, что контейнер у меня настроен так, что он работает внутри VLAN, что сделано в моей домашней инфраструктуре и описано <a href="/blog/2025/11/03/proxmox-network.html">тут</a>, вернее написано, что хорошо бы сделать.</p>

<h4 id="5-установка-драйверов-в-lxc-контейнере">5. Установка драйверов в LXC-контейнере</h4>

<p>Войдите в консоль LXC, в моей инфраструктуре нет доступа по ssh снаружи к контейнерам, так что войти можно:</p>

<ol>
  <li>
    <p>Web console</p>
  </li>
  <li>
    <p><code class="language-plaintext highlighter-rouge">pct enter ID</code> на хосте Proxmox куда по ssh из внутренней сети можно зайти</p>
  </li>
  <li>
    <p>или ssh если настроено</p>
  </li>
</ol>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Установка зависимостей</span>
<span class="nb">sudo </span>apt-get update <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>apt-get upgrade
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> wget gpg mc jq curl docker-compose-v2 tree

<span class="c"># Добавление ключа репозитория Intel</span>
wget <span class="nt">-qO</span> - https://repositories.intel.com/gpu/intel-graphics.key | <span class="se">\</span>
  <span class="nb">sudo </span>gpg <span class="nt">--yes</span> <span class="nt">--dearmor</span> <span class="nt">--output</span> /usr/share/keyrings/intel-graphics.gpg

<span class="c"># Добавление репозитория (для Ubuntu 24.04 'noble')</span>
<span class="c"># Измените 'noble' на кодовое имя вашего дистрибутива, если необходимо, но рекомендую именно этот</span>
<span class="nb">cat</span> <span class="o">&gt;</span> /tmp/intel-gpu.list <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
deb [arch=amd64 signed-by=/usr/share/keyrings/intel-graphics.gpg] https://repositories.intel.com/gpu/ubuntu noble client
</span><span class="no">EOF
</span><span class="nb">sudo mv</span> /tmp/intel-gpu.list /etc/apt/sources.list.d/intel-gpu.list

<span class="c"># Установка драйверов и утилит</span>
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install</span> <span class="nt">-y</span> <span class="se">\</span>
  intel-igc-cm <span class="se">\</span>
  intel-opencl-icd <span class="se">\</span>
  intel-media-va-driver-non-free <span class="se">\</span>
  ocl-icd-libopencl1 <span class="se">\</span>
  clinfo <span class="se">\</span>
  intel-gpu-tools

<span class="c"># Добавление пользователя в группы video и render</span>
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> video,render <span class="nv">$USER</span>
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> docker <span class="nv">$USER</span>
<span class="c"># Применение изменений для текущей сессии</span>
newgrp docker
newgrp render
newgrp video
</code></pre></div></div>

<p>Проверьте доступ к GPU:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>clinfo
</code></pre></div></div>

<p>В выводе будет название вашей видеокарты, у меня так:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ubuntu:~<span class="nv">$ </span>clinfo |tail <span class="nt">-n</span> 10
  clCreateContextFromType<span class="o">(</span>NULL, CL_DEVICE_TYPE_CUSTOM<span class="o">)</span>  No devices found <span class="k">in </span>platform
  clCreateContextFromType<span class="o">(</span>NULL, CL_DEVICE_TYPE_ALL<span class="o">)</span>  Success <span class="o">(</span>1<span class="o">)</span>
    Platform Name                                 Intel<span class="o">(</span>R<span class="o">)</span> OpenCL Graphics
    Device Name                                   Intel<span class="o">(</span>R<span class="o">)</span> Iris<span class="o">(</span>R<span class="o">)</span> Xe Graphics

ICD loader properties
  ICD loader Name                                 OpenCL ICD Loader
  ICD loader Vendor                               OCL Icd free software
  ICD loader Version                              2.3.2
  ICD loader Profile                              OpenCL 3.0
</code></pre></div></div>

<p>Все, проброс GPU из Proxmox хоста в LXC контейнер готов!</p>

<h3 id="шаг-1-развертывание-openvino-model-server">Шаг 1: Развертывание OpenVINO Model Server</h3>

<p>Это как раз сервер, который поддерживает API для доступа к инференсу и эмбеддингам. Сам процесс будет из нескольких этапов:</p>

<ol>
  <li>
    <p>Скачать модель</p>
  </li>
  <li>
    <p>Конвертировать, если нет готовой, ее в формат, понятный OpenVINO, в процессе можно квантизовать и оптимизировать.<br />
Нужно отметить, что есть и готовые модели, уже в формате openvino их конвертировать не нужно, но выбор не велик, их можно искать по “ov” или “openvino” в имени. В статье про интеграцию с ollama целая пачка показана.</p>
  </li>
  <li>
    <p>Развернуть сервер с нужной моделью. Причем особо стоит отметить, что я использую только embeddings модели для своих нужд, но если хочется чатбота, то есть OpenVINO GenAI, который работает поверх OpenVINO Runtime и специально заточен под LLM. Описан <a href="https://medium.com/openvino-toolkit/running-your-genai-app-locally-on-intel-gpu-and-npu-with-openvino-model-server-eb590af29dbc">тут</a> и <a href="https://docs.openvino.ai/2025/get-started/install-openvino/install-openvino-genai.html">тут</a>. Он встроен в контейнер с OVMS насколько я понимаю. Так же этот же OpenVINO умеет еще море всего, генерировать картинки, говорить и так далее. Все для вас.</p>
  </li>
  <li>
    <p>PROFIT</p>
  </li>
</ol>

<h4 id="1-поиск-моделей-для-конвертации">1: Поиск моделей для конвертации</h4>

<p>Все следующие команды выполняются <strong>внутри LXC-контейнера</strong>. Для работы мне нужны будут embedding модели, то есть те, которые строят “вектора смысла” для текста. Причем желательно с встроенным токенизатором что бы не возиться с этим отдельно. Но сервер поддерживает и LLM модели, что в документации описано и так хорошо, не стану расписывать, LLM проще.</p>

<p>Найдите подходящие embedding-модели на Hugging Face:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> <span class="s2">"https://huggingface.co/api/models?search=embedding"</span> | <span class="se">\</span>
  jq <span class="nt">-r</span> <span class="s1">'.models[] | "\(.id) - \(.downloads) загрузок"'</span>
</code></pre></div></div>

<p>Эта команда выведет список моделей с количеством загрузок. Выберите нужную модель, например, <code class="language-plaintext highlighter-rouge">BAAI/bge-large-en-v1.5</code>. Так же есть инструмент для скачивания моделей <a href="https://huggingface.co/docs/huggingface_hub/main/guides/cli">тут</a>, можно использовать и его.</p>

<p>Помимо прочего OVMS умеет и сам качать модели вот <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_pull.html">тут подробности</a> и даже сразу же сам их конвертировать, что описано <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_pull_optimum.html">тут</a></p>

<p>Обратите внимание, что конвертировать модели может быть сложным и запутанным процессом, есть масса готовых <a href="https://huggingface.co/OpenVINO/models">тут</a>, скачать их можно просто с помощью <a href="https://huggingface.co/docs/huggingface_hub/installation#check-installation">этого</a>, каталог и так содержит почти триста моделей на все вкусы. Скачать можно одной командой в итоге <code class="language-plaintext highlighter-rouge">hf download OpenVINO/Qwen3-Embedding-0.6B-int8-ov --local-dir ~/ovms_models/bge-m3-int4-ov</code> просто имя модели нужной задать.</p>

<p>Но что бы было ясно как в случае надобности конвертировать есть следующий шаг.</p>

<h3 id="шаг-2-подготовка-и-конвертация-модели">Шаг 2: Подготовка и конвертация модели</h3>

<h4 id="1-создание-рабочей-директории">1. Создание рабочей директории</h4>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Создайте папку для работы</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/ovms-embeddings
<span class="nb">cd</span> ~/ovms-embeddings

<span class="c"># Создайте папку для моделей</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ./models
</code></pre></div></div>

<h4 id="2-конвертация-модели-в-формат-openvino">2. Конвертация модели в формат OpenVINO</h4>

<p>Тут есть ряд проблем. Дело в том, что если запустить Intel контейнер в режиме <code class="language-plaintext highlighter-rouge">--pull</code> то есть, скачивать модель и потом автоматически конвертировать, то он скачает и конвертирует, но там не будет токенизатор конвертироваться (параметр стоит <code class="language-plaintext highlighter-rouge">--disable-convert-tokenizer</code>), не знаю зачем они эту граблю положили туда. Потому мы могли бы скачать и конвертировать как описано в <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_parameters.html">документации</a>:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--device</span> /dev/dri <span class="nt">--group-add</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s2">"%g"</span> /dev/dri/render<span class="k">*</span> | <span class="nb">head</span> <span class="nt">-n</span> 1<span class="si">)</span> <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span> <span class="nt">--rm</span> <span class="nt">-v</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>/models:/models:rw openvino/model_server:latest-py <span class="nt">--pull</span> <span class="nt">--source_model</span> <span class="s2">"BAAI/bge-large-en-v1.5"</span> <span class="nt">--model_repository_path</span> /models <span class="nt">--model_name</span> bge-large-en-v1.5 <span class="nt">--target_device</span> GPU <span class="nt">--task</span> embeddings <span class="nt">--weight-format</span> int8 <span class="nt">--log_level</span> DEBUG <span class="nt">--overwrite_models</span> <span class="nt">--num_streams</span> 8
</code></pre></div></div>

<p>Но запустим все это напрямую вызвав optimum-cli, выглядит так:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--device</span> /dev/dri <span class="nt">--group-add</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s2">"%g"</span> /dev/dri/render<span class="k">*</span> | <span class="nb">head</span> <span class="nt">-n</span> 1<span class="si">)</span>   <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span> <span class="nt">--rm</span>   <span class="nt">-v</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>/models:/models   <span class="nt">--entrypoint</span> /usr/local/bin/optimum-cli   openvino/model_server:latest-py   <span class="nb">export </span>openvino   <span class="nt">--model</span> BAAI/bge-large-en-v1.5   <span class="nt">--weight-format</span> int8   <span class="nt">--trust-remote-code</span>   <span class="nt">--num-samples</span> 8   <span class="nt">--library</span> sentence_transformers   <span class="nt">--task</span> feature-extraction   ./models/BAAI/bge-large-en-v1.5
</code></pre></div></div>

<p>Если вдруг будут проблемы и я где то не углядел, а первая команда из докунметации скачала и закэшировала модель, потому вторая ее может сразу не качая использовать, то просто выполните обе команды по очереди. Но на данный момент я думаю второй достаточно для всего.<br />
Мы просто напрямую запускаем optimim-cli с нужными параметрами. Хочу отдельно отметить, что для конвертации используется optimum-cli, эту штуку не плохо бы <a href="https://huggingface.co/docs/optimum-intel/en/openvino/export">посмотреть</a> подробнее так как она позволяет подогнать модели под свое железо и оптимизировать их. Есть вагон параметров. Вот полный вывод команд с структурой папок:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ubuntu:~<span class="nv">$ </span><span class="nb">cd</span> ./ovms-embeddings/
i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span><span class="nb">ls
</span>models
i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span>docker run <span class="nt">--device</span> /dev/dri <span class="nt">--group-add</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s2">"%g"</span> /dev/dri/render<span class="k">*</span> | <span class="nb">head</span> <span class="nt">-n</span> 1<span class="si">)</span> <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span> <span class="nt">--rm</span> <span class="nt">-v</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>/models:/models:rw openvino/model_server:latest-py <span class="nt">--pull</span> <span class="nt">--source_model</span> <span class="s2">"BAAI/bge-large-en-v1.5"</span> <span class="nt">--model_repository_path</span> /models <span class="nt">--model_name</span> bge-large-en-v1.5 <span class="nt">--target_device</span> GPU <span class="nt">--task</span> embeddings <span class="nt">--weight-format</span> int8 <span class="nt">--log_level</span> DEBUG <span class="nt">--overwrite_models</span> <span class="nt">--num_streams</span> 8
Unable to find image <span class="s1">'openvino/model_server:latest-py'</span> locally
latest-py: Pulling from openvino/model_server
baa9e71a063a: Pull <span class="nb">complete 
</span>a76d3e698922: Pull <span class="nb">complete 
</span>5bdd5c6068df: Pull <span class="nb">complete 
</span>e1d63ab00fa2: Pull <span class="nb">complete 
</span>97f5e74b6399: Pull <span class="nb">complete 
</span>9cd7a4880448: Pull <span class="nb">complete 
</span>f2fd7d158b30: Pull <span class="nb">complete 
</span>5774c385c156: Pull <span class="nb">complete 
</span>0864fefc1d57: Pull <span class="nb">complete 
</span>1a83f47c7fa9: Pull <span class="nb">complete 
</span>16efed5f6d0e: Pull <span class="nb">complete 
</span>47a75649fb3d: Pull <span class="nb">complete 
</span>21b1671afd79: Pull <span class="nb">complete 
</span>912688daea51: Pull <span class="nb">complete 
</span>94b691fcc499: Pull <span class="nb">complete 
</span>Digest: sha256:5431ac5989c3ae548cb8c954f6caf2a2fafa72ab254fbf90ab5db26381ddd9b8
Status: Downloaded newer image <span class="k">for </span>openvino/model_server:latest-py
<span class="o">[</span>2025-11-17 15:37:04.180][1][serving][debug][optimum_export.cpp:144] Optimum-cli executable is present
<span class="o">[</span>2025-11-17 15:37:04.180][1][serving][debug][optimum_export.cpp:180] Executing <span class="nb">command</span>: optimum-cli <span class="nb">export </span>openvino <span class="nt">--disable-convert-tokenizer</span> <span class="nt">--task</span> feature-extraction <span class="nt">--library</span> sentence_transformers <span class="nt">--model</span> BAAI/bge-large-en-v1.5 <span class="nt">--trust-remote-code</span>  <span class="nt">--weight-format</span> int8 /models/BAAI/bge-large-en-v1.5
/usr/local/lib/python3.12/dist-packages/torch/onnx/_internal/registration.py:162: OnnxExporterWarning: Symbolic <span class="k">function</span> <span class="s1">'aten::scaled_dot_product_attention'</span> already registered <span class="k">for </span>opset 14. Replacing the existing <span class="k">function </span>with new <span class="k">function</span><span class="nb">.</span> This is unexpected. Please report it on https://github.com/pytorch/pytorch/issues.
  warnings.warn<span class="o">(</span>
<span class="sb">`</span>SentenceTransformer._target_device<span class="sb">`</span> has been deprecated, please use <span class="sb">`</span>SentenceTransformer.device<span class="sb">`</span> instead.
<span class="sb">`</span>SentenceTransformer._target_device<span class="sb">`</span> has been deprecated, please use <span class="sb">`</span>SentenceTransformer.device<span class="sb">`</span> instead.
<span class="sb">`</span><span class="nv">loss_type</span><span class="o">=</span>None<span class="sb">`</span> was <span class="nb">set </span><span class="k">in </span>the config but it is unrecognised.Using the default loss: <span class="sb">`</span>ForCausalLMLoss<span class="sb">`</span><span class="nb">.</span>
Model: BAAI/bge-large-en-v1.5 downloaded to: /models/BAAI/bge-large-en-v1.5
<span class="o">[</span>2025-11-17 15:37:40.150][1][serving][debug][filesystem.cpp:191] Creating file /models/BAAI/bge-large-en-v1.5/graph.pbtxt
Graph: graph.pbtxt created <span class="k">in</span>: /models/BAAI/bge-large-en-v1.5
i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span>tree <span class="nb">.</span>
<span class="nb">.</span>
<span class="sb">`</span><span class="nt">--</span> models
    <span class="sb">`</span><span class="nt">--</span> BAAI
        <span class="sb">`</span><span class="nt">--</span> bge-large-en-v1.5
            |-- config.json
            |-- graph.pbtxt
            |-- openvino_model.bin
            |-- openvino_model.xml
            |-- special_tokens_map.json
            |-- tokenizer.json
            |-- tokenizer_config.json
            <span class="sb">`</span><span class="nt">--</span> vocab.txt

4 directories, 8 files
i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span>docker run <span class="nt">--device</span> /dev/dri <span class="nt">--group-add</span><span class="o">=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s2">"%g"</span> /dev/dri/render<span class="k">*</span> | <span class="nb">head</span> <span class="nt">-n</span> 1<span class="si">)</span>   <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span> <span class="nt">--rm</span>   <span class="nt">-v</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>/models:/models   <span class="nt">--entrypoint</span> /usr/local/bin/optimum-cli   openvino/model_server:latest-py   <span class="nb">export </span>openvino   <span class="nt">--model</span> BAAI/bge-large-en-v1.5   <span class="nt">--weight-format</span> int8   <span class="nt">--trust-remote-code</span>   <span class="nt">--num-samples</span> 8   <span class="nt">--library</span> sentence_transformers   <span class="nt">--task</span> feature-extraction   ./models/BAAI/bge-large-en-v1.5
/usr/local/lib/python3.12/dist-packages/torch/onnx/_internal/registration.py:162: OnnxExporterWarning: Symbolic <span class="k">function</span> <span class="s1">'aten::scaled_dot_product_attention'</span> already registered <span class="k">for </span>opset 14. Replacing the existing <span class="k">function </span>with new <span class="k">function</span><span class="nb">.</span> This is unexpected. Please report it on https://github.com/pytorch/pytorch/issues.
  warnings.warn<span class="o">(</span>
<span class="sb">`</span>SentenceTransformer._target_device<span class="sb">`</span> has been deprecated, please use <span class="sb">`</span>SentenceTransformer.device<span class="sb">`</span> instead.
<span class="sb">`</span>SentenceTransformer._target_device<span class="sb">`</span> has been deprecated, please use <span class="sb">`</span>SentenceTransformer.device<span class="sb">`</span> instead.
<span class="sb">`</span><span class="nv">loss_type</span><span class="o">=</span>None<span class="sb">`</span> was <span class="nb">set </span><span class="k">in </span>the config but it is unrecognised.Using the default loss: <span class="sb">`</span>ForCausalLMLoss<span class="sb">`</span><span class="nb">.</span>
INFO:nncf:Statistics of the bitwidth distribution:
┍━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┑
│ Weight compression mode   │ % all parameters <span class="o">(</span>layers<span class="o">)</span>   │ % ratio-defining parameters <span class="o">(</span>layers<span class="o">)</span>   │
┝━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┥
│ int8_asym                 │ 100% <span class="o">(</span>147 / 147<span class="o">)</span>            │ 100% <span class="o">(</span>147 / 147<span class="o">)</span>                       │
┕━━━━━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┙
Applying Weight Compression ━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% • 0:00:02 • 0:00:00
i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span>tree <span class="nb">.</span>
<span class="nb">.</span>
<span class="sb">`</span><span class="nt">--</span> models
    <span class="sb">`</span><span class="nt">--</span> BAAI
        <span class="sb">`</span><span class="nt">--</span> bge-large-en-v1.5
            |-- config.json
            |-- graph.pbtxt
            |-- openvino_detokenizer.bin
            |-- openvino_detokenizer.xml
            |-- openvino_model.bin
            |-- openvino_model.xml
            |-- openvino_tokenizer.bin
            |-- openvino_tokenizer.xml
            |-- special_tokens_map.json
            |-- tokenizer.json
            |-- tokenizer_config.json
            <span class="sb">`</span><span class="nt">--</span> vocab.txt

4 directories, 12 files
</code></pre></div></div>

<p>Собственно мы добивались появления <code class="language-plaintext highlighter-rouge">openvino_detokenizer.bin</code>, <code class="language-plaintext highlighter-rouge">openvino_detokenizer.xml</code> когда запускали отдельно конвертацию токенизатора.</p>

<p>Далее <strong>просто пример</strong> нам это не нужно. Можно сделать конвертацию и полностью из кода или даже токенизатор использовать свой это не то что бы сложно, но предположим, что хотим не возиться. Далее пример вовсе конвертации модели скриптом, эта страшная весчь делает простое действие, качает скрипт, который и конвертирует модель. Только имя модели поменять и параметры конвертации. <code class="language-plaintext highlighter-rouge">--weight-format</code> это квантизация, <code class="language-plaintext highlighter-rouge">--target_device</code> это собственно под, что затачивать будем, CPU/GPU. Есть гибридные варианты, но я не стал копаться и так мне достаточно. Гибридные, GPU+CPU, описаны <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_target_devices.html">тут</a>, так же там описано как использовать NPU от intel, но первые варианты NPU ограниченны относительно возможностей GPU. Работает этот вариант конвертации значительно дольше так как зависимости долго ставит в контейнере. Просто для примера привел.</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--rm</span> <span class="se">\</span>
  <span class="nt">--user</span> <span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span>:<span class="si">$(</span><span class="nb">id</span> <span class="nt">-g</span><span class="si">)</span> <span class="se">\</span>
  <span class="nt">-v</span> <span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span>/models:/workspace/models <span class="se">\</span>
  <span class="nt">-w</span> /workspace <span class="se">\</span>
  openvino/ubuntu24_dev:latest <span class="se">\</span>
  bash <span class="nt">-c</span> <span class="s2">"</span><span class="se">\</span><span class="s2">
    curl -L https://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/releases/2025/3/demos/common/export_models/export_model.py -o export_model.py &amp;&amp; </span><span class="se">\</span><span class="s2">
    pip install -r https://raw.githubusercontent.com/openvinotoolkit/model_server/refs/heads/releases/2025/3/demos/common/export_models/requirements.txt &amp;&amp; </span><span class="se">\</span><span class="s2">
    python3 export_model.py embeddings_ov </span><span class="se">\</span><span class="s2">
      --source_model BAAI/bge-large-en-v1.5 </span><span class="se">\</span><span class="s2">
      --weight-format fp16 </span><span class="se">\</span><span class="s2">
      --target_device GPU </span><span class="se">\</span><span class="s2">
      --config_file_path /workspace/models/config.json </span><span class="se">\</span><span class="s2">
      --model_repository_path /workspace/models"</span>
</code></pre></div></div>

<p>После скрипта нужно исправление конфигурации графа. Скрипт экспорта, тот страшный выше в ненужном примере, добавляет параметр <code class="language-plaintext highlighter-rouge">NUM_STREAMS</code>, который может вызывать проблемы при работе с GPU. Проблемы можно увидеть в логах сервера при старте и если нужно, можно отредактировать или удалить данный раздел конфига графа вычислений. Это именно конфиг графа вычислений, не конфига сервера:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Удалите проблемную строку из graph.pbtxt</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'/plugin_config: \'</span><span class="se">\'\'</span><span class="o">{</span><span class="s2">"NUM_STREAMS"</span>: 1<span class="o">}</span><span class="se">\'\'\'</span>/d<span class="s1">' \
  ./models/BAAI/bge-large-en-v1.5/graph.pbtxt
</span></code></pre></div></div>

<p>Проверьте, что строка удалена:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Просмотрите содержимое файла</span>
<span class="nb">cat</span> ./models/BAAI/bge-large-en-v1.5/graph.pbtxt
</code></pre></div></div>

<p>В файле не должно быть упоминания <code class="language-plaintext highlighter-rouge">NUM_STREAMS</code>. Редактировать можно с nano/vi чем угодно конечно. Но в случае если вы использовали конвертацию с докером и optimum-cli, это не нужно все, привел для упрощения поиска граблей желающим погрузиться в ручной труд.<br />
Докер в данном случае спасает от установки среды разработки этого всего барахла и там совместить все версии всего так что бы оно работало ох как не просто. Можете посмотреть отдельно контейнер для разработчика, описано <a href="https://github.com/openvinotoolkit/docker_ci/blob/master/docs/tutorials/OV_docker_usage.ipynb">тут</a>. Так же обратите внимание, что мы используем контейнер для конвертации <code class="language-plaintext highlighter-rouge">openvino/model_server:latest-py</code>, запускать будем <code class="language-plaintext highlighter-rouge">openvino/model_server:latest-gpu</code>, а для разработки вовсе нужен <code class="language-plaintext highlighter-rouge">openvino/ubuntu24_dev</code> и там в каждом полная среда настроена.</p>

<h4 id="3-создание-файла-конфигурации">3. Создание файла конфигурации</h4>

<p>Теперь снова то, что нам нужно. Для того что бы сервер нашел модель или модели нужен файл конфигурации:</p>

<p>Команды</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ubuntu:~/ovms-embeddings$ cat ./models/config.json 
{
    "model_config_list": [{
        "config": {
            "name": "BAAI/bge-large-en-v1.5",
            "base_path": "/models/BAAI/bge-large-en-v1.5",
            "target_device": "GPU"
        }
    }],
    "mediapipe_config_list": [{
        "name": "BAAI/bge-large-en-v1.5",
        "graph_path": "/models/BAAI/bge-large-en-v1.5/graph.pbtxt"
    }]
}
</code></pre></div></div>

<p>Посмотреть подробности и даже несколько моделей одновременно что бы было, можно <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_serving_model.html">тут</a>.<br />
Где-то в логах мелькало, что при конвертации взят только первый слой графа или что-то подобное, но я пока вникать не стал и без того пришлось перелопатить много всего, не уверен даже, что это в итоговый заход попало с этими командами, но, можете повнимательнее посмотреть логи.</p>

<h4 id="4-проверка-результата">4. Проверка результата</h4>

<p>Вы должны увидеть папки с моделью, токенизатором и файлом <code class="language-plaintext highlighter-rouge">config.json</code>. Примерно такие:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@ubuntu:~/ovms-embeddings<span class="nv">$ </span>tree <span class="nb">.</span>
<span class="nb">.</span>
|-- docker-compose.yml &lt;<span class="nt">--</span> <span class="c"># еще сделаем</span>
<span class="sb">`</span><span class="nt">--</span> models
    |-- BAAI
    |   <span class="sb">`</span><span class="nt">--</span> bge-large-en-v1.5
    |       |-- config.json
    |       |-- graph.pbtxt
    |       |-- openvino_detokenizer.bin
    |       |-- openvino_detokenizer.xml
    |       |-- openvino_model.bin
    |       |-- openvino_model.xml
    |       |-- openvino_tokenizer.bin
    |       |-- openvino_tokenizer.xml
    |       |-- special_tokens_map.json
    |       |-- tokenizer.json
    |       |-- tokenizer_config.json
    |       <span class="sb">`</span><span class="nt">--</span> vocab.txt
    <span class="sb">`</span><span class="nt">--</span> config.json

4 directories, 14 files
</code></pre></div></div>

<h3 id="шаг-3-создание-конфигурации-docker-compose">Шаг 3: Создание конфигурации Docker Compose</h3>

<h4 id="1-создание-файла-env">1. Создание файла .env</h4>

<p>Создайте файл с переменными окружения, используя GID который вы записали в самом начале.</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Замените 993 GID был уменя, ваш GID группы render с хоста Proxmox будет совпадать с LXC</span>
<span class="nb">echo</span> <span class="s2">"RENDER_GID=</span><span class="si">$(</span><span class="nb">stat</span> <span class="nt">-c</span> <span class="s1">'%g'</span> /dev/dri/renderD128<span class="si">)</span><span class="s2">"</span> <span class="o">&gt;</span> .env
<span class="c"># Замените 1000 на ваш UID пользователя в LXC (если отличается)</span>
<span class="nb">echo</span> <span class="s2">"YOUR_UID=</span><span class="si">$(</span><span class="nb">id</span> <span class="nt">-u</span><span class="si">)</span><span class="s2">"</span> <span class="o">&gt;&gt;</span> .env

<span class="c"># Проверьте содержимое файла</span>
<span class="nb">cat</span> .env
</code></pre></div></div>

<p>Вывод должен быть примерно таким:</p>

<p>Команды</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RENDER_GID=993
YOUR_UID=1000
</code></pre></div></div>

<h4 id="2-создание-файла-docker-composeyml">2. Создание файла docker-compose.yml</h4>

<p>Тут все просто, поднимаем сервер, указываем ему конфиг и порт на котором работать:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&gt;</span> docker-compose.yml <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
services:
  bge-large-embeddings:
    image: openvino/model_server:latest-gpu
    container_name: bge-large-embeddings
    user: "</span><span class="k">${</span><span class="nv">YOUR_UID</span><span class="k">}</span><span class="sh">:</span><span class="k">${</span><span class="nv">YOUR_UID</span><span class="k">}</span><span class="sh">"
    ports:
      - "8001:8000"
    devices:
      - /dev/dri:/dev/dri
    group_add:
      - "</span><span class="k">${</span><span class="nv">RENDER_GID</span><span class="k">}</span><span class="sh">"
    volumes:
      - ./models:/models:ro
    command: &gt;
      --config_path /models/config.json
      --rest_port 8000
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="шаг-4-запуск-и-проверка-сервера">Шаг 4: Запуск и проверка сервера</h3>

<h4 id="1-запуск-сервера">1. Запуск сервера</h4>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Запустите сервер в фоновом режиме</span>
docker compose up <span class="nt">-d</span>
</code></pre></div></div>

<h4 id="2-мониторинг-запуска">2. Мониторинг запуска</h4>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Просмотр логов контейнера в реальном времени</span>
docker compose logs <span class="nt">-f</span>
</code></pre></div></div>

<p>Дождитесь сообщения о том, что сервер запущен и модель загружена. Обычно это занимает 15-20 секунд. Для выхода из просмотра логов нажмите <code class="language-plaintext highlighter-rouge">Ctrl+C</code>.</p>

<h4 id="3-проверка-статуса-контейнера">3. Проверка статуса контейнера</h4>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Проверьте, что контейнер запущен</span>
docker compose ps
</code></pre></div></div>

<p>Вы должны увидеть контейнер <code class="language-plaintext highlighter-rouge">bge-large-embeddings</code> в статусе <code class="language-plaintext highlighter-rouge">Up</code>.</p>

<h4 id="4-отправка-тестового-запроса">4. Отправка тестового запроса</h4>

<p>OVMS поддерживает довольно обширное API, описанное <a href="https://docs.openvino.ai/2025/model-server/ovms_docs_server_app.html">тут</a> и может маскироваться под самые разные сервера. Так можно получить состояние модели:</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@openvino:~/openvino/big<span class="nv">$ </span>curl http://localhost:8001/v1/config
<span class="o">{</span>
<span class="s2">"BAAI/bge-large-en-v1.5"</span> : 
<span class="o">{</span>
 <span class="s2">"model_version_status"</span>: <span class="o">[</span>
  <span class="o">{</span>
   <span class="s2">"version"</span>: <span class="s2">"1"</span>,
   <span class="s2">"state"</span>: <span class="s2">"AVAILABLE"</span>,
   <span class="s2">"status"</span>: <span class="o">{</span>
    <span class="s2">"error_code"</span>: <span class="s2">"OK"</span>,
    <span class="s2">"error_message"</span>: <span class="s2">"OK"</span>
   <span class="o">}</span>
  <span class="o">}</span>
 <span class="o">]</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Так можно получить собственно то, ради чего все затевалось, а именно embedding вектор для заданного текста</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Отправьте запрос на получение эмбеддинга</span>
curl <span class="nt">-X</span> POST http://localhost:8001/v3/embeddings <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{
    "model": "BAAI/bge-large-en-v1.5",
    "input": "The entire stack, from Proxmox to OVMS, is fully operational."
  }'</span> | jq
</code></pre></div></div>

<p>Вы должны получить JSON-ответ с векторным представлением текста, примерно такого вида:</p>

<p>Json</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"list"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"data"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"object"</span><span class="p">:</span><span class="w"> </span><span class="s2">"embedding"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"embedding"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mf">0.123</span><span class="p">,</span><span class="w"> </span><span class="mf">-0.456</span><span class="p">,</span><span class="w"> </span><span class="mf">0.789</span><span class="p">,</span><span class="w"> </span><span class="err">...</span><span class="p">],</span><span class="w">
      </span><span class="nl">"index"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"BAAI/bge-large-en-v1.5"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"usage"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"prompt_tokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="p">,</span><span class="w">
    </span><span class="nl">"total_tokens"</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h4 id="5-проверка-использования-gpu">5. Проверка использования GPU</h4>

<p>Можно посмотреть нагрузку на GPU, но запускать нужно на Proxmox хосте, в LXC оно заработать не захотело и я не стал разбираться почему.</p>

<p>Команды</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># В отдельном терминале запустите мониторинг GPU</span>
intel_gpu_top
</code></pre></div></div>

<p>Выше изложена основа для того что бы на своем домашнем сервере завести себе LLM/Embedding модели, которые например будут полезны для тегирования фоточек, просто как чатботы или например для построения RAG системы через MCP для вашего любимого LLM на основе embedding моделей. В документации OpenVINO есть множество статей с примерами, рекомендую. Позже покажу для чего это мне нужно, наверное, как кобычно по настроению. Выдался просто выходной внеочередной.</p>]]></content><author><name></name></author><category term="proxmox" /><category term="lxc" /><category term="openvino" /><category term="ml" /><category term="gpu passthrough" /><category term="embeddings" /><summary type="html"><![CDATA[В прошлой статье я описывал как построить сетевую часть самодержавного сервера, пора принести в него что-то отдаленно разумное. Это руководство описывает весь процесс: от подготовки хоста Proxmox и настройки LXC-контейнера до поиска, конвертации и запуска embedding-моделей (на примере BAAI/bge-large-en-v1.5 ) с использованием Intel GPU для работы модели. Но будет легко запустить не одну модель или полноценного чатбота на этой основе. Главное, что будет ясно как использовать даже простое имеющееся железо домашнего сервера для этого.]]></summary></entry><entry><title type="html">Личное облако на Proxmox: сеть</title><link href="https://3draven.github.io/blog/2025/11/03/proxmox-network.html" rel="alternate" type="text/html" title="Личное облако на Proxmox: сеть" /><published>2025-11-03T19:25:22+00:00</published><updated>2025-11-03T19:25:22+00:00</updated><id>https://3draven.github.io/blog/2025/11/03/proxmox-network</id><content type="html" xml:base="https://3draven.github.io/blog/2025/11/03/proxmox-network.html"><![CDATA[<p>Как-то я писал статью “Личное облако на Proxmox” где описывал как установить Proxmox разными способами и в целом, что это такое, обещал, что когда то, под настроение опишу продолжение про сетевую часть домашнего облака с сервисами, доступными из интернет на домашнем сервере, вот, пожалуйста.</p>

<p>Прошлая <a href="/blog/2024/03/02/proxmox-personal-cloud.html">статья</a> была посвящена базовой настройке. Что сейчас я имею в итоге:</p>

<ol>
  <li>
    <p>Домашний сервер Asus NUC, на нем установлен Proxmox, правда я не стал ставить на десктопный Debian, так как передумал делать ему красивую панельку с экранчиком, а значит графический интерфейс ему не нужен и можно просто поставить с iso образа, который предоставляют авторы Proxmox.</p>
  </li>
  <li>
    <p>Роутер к которому подключен сервер и домашняя техника, например мой ноутбук. На роутере OpenWRT. Интернет обычный домашний, без статического IP адреса.</p>
  </li>
  <li>
    <p>VPS в интернете за три копейки с ubuntu 25.04 server. Проверьте что бы у VPS IP адрес в базах спамеров и прочих блок листах не был, а то сайты вас пускать не будут :) Проверить можно <a href="https://mxtoolbox.com/SuperTool.aspx">тут</a>, там есть `blacklist check`.</p>
  </li>
</ol>

<p>Задача:</p>

<ol>
  <li>
    <p>Установить домашний сервис в изолированном окружении.</p>
  </li>
  <li>
    <p>Предоставить доступ к домашним сервисам из интернета.</p>
  </li>
  <li>
    <p>Сохранить доступ к админкам из домашней сети.</p>
  </li>
</ol>

<p>Начнем. Заранее предупрежу, что вероятно некоторые настройки, описанные в статье так как я их помню, но должны быть сделаны раньше чем в статье, уж как помню, прочитайте статью целиком прежде чем начать делать.</p>

<p>Обратите внимание, что так как нейронки сейчас отлично могут советовать настройки, если что не ясно, спросите их, просто скопипастив кусок статьи. В случае возникновения проблем, проще всего диагностировать их так, заходите в терминал всех сервисов и серверов, по ssh или в proxmox console и на каждом интерфейсе каждого сервиса запускаете tcpdump -i ${название интерфейсов сетевых}, после чего делаете коннект к VPS или ping и смотрите через какие интерфейсы и что пролетело. Можно отфильтровать лишнее например ssh коннект свой из ловли, что бы контекст нейронки не переполнить, они в целом хорошо понимают до 3к строк текста всего, обычно, по моему опыту. Причем даже если прислать больше они нередко отрезают первые 5к строк кодом на питоне самодельным, так что делите на части. Потом наловленное скармливаете нейронке и просите проанализировать. Она быстро вам опишет где цепочка передачи данных оборвалась.</p>

<p>При этом, нейронку можно попросить написать скрипты, которые скинут в файл настройки фаерволов, таблицы маршрутизации и интерфейсы сетевые, что бы ей можно было промпты потом из этого делать быстро, файл итоговый приложил и все. Я такой скрипт на своему ноутбуке запускаю, он подключается ко всем серверам, что описаны ниже и всю нужную информацию собирает в файл, это экономит дни диагностики проблем.</p>

<p>Первое, что нужно сделать это настроить OpenWRT так что бы br-lan, интерфейс, который там создается сам, обслуживал две VLAN. В одной будет домашняя техника, во второй будет Proxmox. Это нужно что бы изолировать и обезопасить домашнюю технику. Заходим в LuCI в настройки Network-&gt;Interfaces-&gt;Devices-&gt;br-lan-&gt;Configure-&gt;Bridge VLAN filtering. Там создаем VLAN 100 и VLAN 200, разрешаем VLAN как таковые. В VLAN 200 будет Proxmox, сервер кабелем подключен к какому то LAN порту, вот его туда и добавить как Untagged. Untagged значит, что на стороне самого Proxmox никаких VLAN настроек мы делать не будем, он не будет ставить tags. Остальные порты добавляем в VLAN 100 так же Untagged. Этим мы разделим домашнюю сеть на две подсети, в которых будут отдельно обитать наши сервисы и домашняя техника. Так же нужно добавить пару правил firewall. Я не буду расписывать кнопки это долго, приведу конфиги, которые получатся в итоге и нейронка вам расскажет, что жать.</p>

<p>Это конфиг сети:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@OpenWrt:~# cat /etc/config/network

config interface 'loopback'
    option device 'lo'
    option proto 'static'
    option ipaddr '127.0.0.1'
    option netmask '255.0.0.0'

config globals 'globals'
    option ula_prefix 'fd61:eb10:9fdd::/48'
    option packet_steering '1'

config device
    option name 'br-lan'
    option type 'bridge'
    list ports 'lan1'
    list ports 'lan2'
    list ports 'lan3'
    list ports 'lan4'
    option ipv6 '0'

config interface 'lan'
    option device 'br-lan.100'
    option proto 'static'
    option ipaddr '192.168.1.1'
    option netmask '255.255.255.0'
    option ip6assign '60'
    option ipv6 'off'

config interface 'wan'
    option device 'wan'
    option proto 'dhcp'
    option peerdns '0'
    list dns '9.9.9.9'
    list dns '149.112.112.112'
    option ipv6 'off'

config device
    option name 'phy1-ap0'
    option ipv6 '0'

config device
    option name 'phy0-ap0'
    option ipv6 '0'

config device
    option name 'wan'
    option ipv6 '0'

config device
    option name 'miireg'
    option ipv6 '0'

config device
    option name 'lan4'
    option ipv6 '0'

config device
    option name 'lan3'
    option ipv6 '0'

config device
    option name 'lan2'
    option ipv6 '0'

config device
    option name 'lan1'
    option ipv6 '0'

config device
    option name 'br-wan'
    option type 'bridge'
    list ports 'wan'
    option mtu '1500'
    option ipv6 '0'

config bridge-vlan
    option device 'br-lan'
    option vlan '100'
    list ports 'lan1'
    list ports 'lan2'
    list ports 'lan3'

config bridge-vlan
    option device 'br-lan'
    option vlan '200'
    list ports 'lan4'

config interface 'proxmox'
    option proto 'static'
    option device 'br-lan.200'
    option ipaddr '192.168.2.1'
    option netmask '255.255.255.0'
</code></pre></div></div>

<p>Это конфиг firewall:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@OpenWrt:~# cat /etc/config/firewall

config defaults
    option input 'DROP'
    option output 'DROP'
    option forward 'REJECT'
    option synflood_protect '1'
    option drop_invalid '1'

config zone
    option name 'lan'
    option input 'ACCEPT'
    option output 'ACCEPT'
    option forward 'ACCEPT'
    list network 'lan'

config zone
    option name 'wan'
    option input 'DROP'
    option output 'ACCEPT'
    option forward 'DROP'
    option mtu_fix '1'
    option masq '1'
    list network 'wan'

config forwarding
    option src 'lan'
    option dest 'wan'

config zone
    option name 'proxmox'
    option input 'REJECT'
    option output 'ACCEPT'
    option forward 'REJECT'
    list network 'proxmox'

config forwarding
    option src 'lan'
    option dest 'proxmox'

config rule
    option name 'Allow LAN to Proxmox Management'
    option src 'lan'
    option dest 'proxmox'
    option dest_ip '192.168.2.2'
    option dest_port '8006'
    option proto 'tcp'
    option target 'ACCEPT'

config rule
    option name 'Allow LAN to Proxmox SSH'
    option src 'lan'
    option dest 'proxmox'
    option dest_ip '192.168.2.2'
    option dest_port '22'
    option proto 'tcp'
    option target 'ACCEPT'

config rule
    option name 'Allow LAN to OPNsense Web'
    option src 'lan'
    option dest 'proxmox'
    option dest_ip '192.168.2.3'
    option dest_port '443'
    option proto 'tcp'
    option target 'ACCEPT'

config rule
    option name 'Allow LAN to OPNsense HTTP'
    option src 'lan'
    option dest 'proxmox'
    option dest_ip '192.168.2.3'
    option dest_port '80'
    option proto 'tcp'
    option target 'ACCEPT'

config rule
    option src 'lan'
    option dest 'proxmox'
    option name 'Allow LAN to Proxmox icmp'
    list proto 'icmp'
    option target 'ACCEPT'

config forwarding
    option src 'proxmox'
    option dest 'wan'

config rule
    option src 'proxmox'
    option name 'Allow-ICMP-from-Proxmox'
    list proto 'icmp'
    option target 'ACCEPT'

config rule
    option src 'proxmox'
    option target 'ACCEPT'
    option name 'Proxmox DNS to OpenWRT'
    option dest_port '53'
    list proto 'tcp'
    list proto 'udp'

config rule
    option src 'proxmox'
    option dest 'lan'
    option name 'Block proxmox to LAN'
    list proto 'all'
    option target 'REJECT'
</code></pre></div></div>

<p>Тут нужно отметить, что Proxmox сервер у меня настроен с статическим адресом, выдавать серверу адрес по DHCP кажется излишним. Некоторые правила, не относящиеся к задаче я убрал.</p>

<p>По умолчанию в OpenWRT используется для локальнных адресов зона lan, я заменил ее на более правильную home.arpa, а так же добавил DNS записи для нужных мне локальных доменов. Это делается в настройках DNS, если нужно разберетесь, это просто удобство. Так что теперь я могу писать что то вроде proxmox.home.arpa в браузере и открывать админку. Но тут важно то, что Caddy понимает, что home.arpa это локальный адрес, а вот lan она не понимает и потому L4 Proxy, который нам нужен будет, с lan не будет работать.</p>

<p>Далее нужно создать в Proxmox сетевой мост, который будет изолирован от всего. Это позволит обеспечить полную изоляцию наших сервисов, которые мы выставим в интернет, не только внутри VLAN OpenWRT, но и внутри Proxmox хоста. Вот так будет в итоге выглядеть сеть в Proxmox, это все делается с UI, но итог проще показать в виде конфига:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pve:~# cat /etc/network/interfaces
auto lo
iface lo inet loopback

iface enp114s0 inet manual

iface enx000ec65482c4 inet manual

iface wlo1 inet manual

auto vmbr0
iface vmbr0 inet static
    address 192.168.2.2/24
    gateway 192.168.2.1
    bridge-ports enx000ec65482c4
    bridge-stp off
    bridge-fd 0
    nameservers 192.168.2.1

auto services
iface services inet manual
    bridge-ports none
    bridge-stp off
    bridge-fd 0
#Internal services network
</code></pre></div></div>

<p>В прошлой статье я уже описывал как делать в Proxmox виртуалки, так что сейчас не стану делать это подбробно. Следующим нужно сделать виртуальную машину и установить туда <a href="https://opnsense.org/">OPNSense</a> он, по моему скромному опыту, намного удобнее и стабильнее чем pf-sense, который я собирался использовать в прошлой статье, главное UI не глючный и все очень ясно описано. Это универсальный комбайн для firewall и, что нам понадобится, он имеет community plugin Caddy, это реверси прокси. Вот такая конфигурация VM где установлен OPNSense:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pve:~# qm config 100
boot: order=scsi0
cores: 4
cpu: x86-64-v2-AES
efidisk0: local-btrfs:100/vm-100-disk-0.raw,efitype=4m,pre-enrolled-keys=1,size=528K
machine: q35
memory: 8192
meta: creation-qemu=10.0.2,ctime=1761775163
name: opensense
net0: virtio=MAC,bridge=vmbr0,firewall=1,queues=4
net1: virtio=MAC,bridge=services,firewall=1,queues=4
numa: 0
onboot: 1
ostype: l26
parent: P4
scsi0: local-btrfs:100/vm-100-disk-1.raw,discard=on,iothread=1,size=64G
scsihw: virtio-scsi-single
smbios1: uuid=f0313399-c474-416a-9134-8b64b1585a5b
sockets: 1
vmgenid: edf40b71-8f32-46de-b31b-831768f16de4
</code></pre></div></div>

<p>В процессе установки OPNSense нужно в качестве WAN выбрать vmbr0 от Proxmox, а в качестве LAN выбрать мост services, изолирующий наши внутренние сервисы. В консоли OPNSense они будут называться vtnet0 vtnet1, соответственно. Для vtnet0 нужно задать статический адрес так же как для Proxmox, так как такому важному участнику сети выдавать адрес по DHCP кажется не разумным. В целом для VLAN 200 я DHCP отключил на OpenWRT, потому, что в процессе жизни этой инфраструктуры в VLAN 200 больше не появится никого нового.</p>

<p>Настройки firewall, приведенные выше, разрешают доступ из VLAN 100 то есть с домашнего ноутбука к VLAN 200, но не наоборот. Так что после того как VM OPNSense будет запущена можно будет с ноутбука зайти на адрес, заданный для OPNSense, в моем случае 192.168.2.3 и увидеть его админку. Напомню, что я настроил DNS имена в OpenWRT, так что я могу в браузере зайти на opnsense.home.arpa и попасть в админку. Вот такой у OPNSense итоговый конфиг сети на этом этапе настройки:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@OPNsense:~ # ifconfig
vtnet0: flags=1008843&lt;UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP&gt; metric 0 mtu 1500
    description: WAN (wan)
    options=80028&lt;VLAN_MTU,JUMBO_MTU,LINKSTATE&gt;
    ether bc:24:11:99:e5:59
    inet 192.168.2.3 netmask 0xffffff00 broadcast 192.168.2.255
    media: Ethernet autoselect (10Gbase-T &lt;full-duplex&gt;)
    status: active
    nd6 options=29&lt;PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL&gt;
vtnet1: flags=1008843&lt;UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST,LOWER_UP&gt; metric 0 mtu 1500
    description: LAN (lan)
    options=80028&lt;VLAN_MTU,JUMBO_MTU,LINKSTATE&gt;
    ether bc:24:11:44:2a:46
    inet 10.0.0.1 netmask 0xffffff00 broadcast 10.0.0.255
    media: Ethernet autoselect (10Gbase-T &lt;full-duplex&gt;)
    status: active
    nd6 options=29&lt;PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL&gt;
</code></pre></div></div>

<p>Когда это сделано мы получаем ситуацию когда OPNSense является шлюзом для доступа во внешний мир для всех изолированных сервисов за мостом services. Так что весь их трафик проходит через него. Так как у нас есть изоляция OpenWRT firewall+VLAN, изоляция services внутри моста и OPNSense в качестве firewall, я отключил Proxmox firewall, слишком много было бы изоляций, да и к тому же Proxmox firewall постоянно ломался.</p>

<p>Следующим шагом нужно установить первый сервис, в качестве первого сервиса я выбрал xray. Для того что бы он работал нужно не так и много, есть отличная серия статей на эту тему <a href="https://habr.com/ru/articles/839656/">статья1</a>, <a href="https://habr.com/ru/articles/770400/">статья2</a>, <a href="https://habr.com/ru/articles/731608/">статья3</a>, <a href="https://habr.com/ru/articles/735536/">статья4</a>, <a href="https://habr.com/ru/articles/728696/">статья5</a>. Так же я нашел хорошее описание <a href="https://hostkey.com/documentation/marketplace/security/3x_ui/">тут</a> и <a href="https://github.com/MHSanaei/3x-ui/wiki/Installation#install-in-one-line-recommended">тут</a>. Итогом будет установка LXC контейнера с ubuntu, вот конфиг LXC контейнера:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@pve:~# pct config 102
arch: amd64
cores: 2
features: nesting=1
hostname: xray
memory: 2048
nameserver: 10.0.0.1
net0: name=eth0,bridge=services,firewall=1,hwaddr=BC:24:11:9A:65:73,ip=dhcp,type=veth
ostype: ubuntu
parent: VPNWorks
rootfs: local-btrfs:102/vm-102-disk-0.raw,size=8G
swap: 0
unprivileged: 1
</code></pre></div></div>

<p>В LXC контейнере можно запускать вложенные docker контейнеры, так что внтутри я запустил xray в docker compose, так просто удобнее ничего не нужно настраивать, с таким docker-compose.yml:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>root@xray:~# cat  /home/i3draven/panel/docker-compose.yml 
services:
  3xui:
    image: ghcr.io/mhsanaei/3x-ui:latest
    container_name: 3xui_app
    hostname: my.host.name # если надо
    volumes:
      - $PWD/db/:/etc/x-ui/
      - $PWD/cert/:/root/cert/
    environment:
      XRAY_VMESS_AEAD_FORCED: "false"
      XUI_ENABLE_FAIL2BAN: "true"
    tty: true
    network_mode: host
    restart: unless-stopped
</code></pre></div></div>

<p>После `docker compose up -d` контейнер будет запущен и готов к работе. В статьях, что я приводил выше описано как сделать соединение VLESS+Reality в админке. Тут нужно отметить, что именно VLESS+Reality наиболее продвинутый способ и я рекомендую именно его. Но в админку надо для начала попасть, для чего нам и нужен будет Caddy. Прежде чем устанавливать Caddy в OPNSense-&gt;System-&gt;Settings поменяйте порт, на котором будет работать админка OPNSense например на 8443, позже к ней мы доступ сделаем тоже через Caddy, мне так кажется удобным. Далее заходите в OPNSense и устанавливаете community plugin os-caddy.</p>

<p>После установки плагина нужно сделать несколько настроек в плагине. Тут важно понимать, что есть два разных механизма, первый это reverse proxy и второй L4 Proxy в Caddy. Первым мы предоставим доступ к админкам, он работает на L7 сетевом уровне, а вторым будет работать VLESS+Reality так как он нуждается в L4 уровне прокисрования. Вот такой конфиг для Caddy должен получится в итоге:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># DO NOT EDIT THIS FILE -- OPNsense auto-generated file


# caddy_user=root

# Global Options
{
    log {
        output net unixgram//var/run/caddy/log.sock {
        }
        format json {
            time_format rfc3339
        }
        level DEBUG
    }

    servers {
        protocols h1 h2

        log_credentials
        listener_wrappers {
            layer4 {
                import /usr/local/etc/caddy/caddy.d/*.layer4listener

                @bea500f4-0d14-431d-9bba-87ec8df540f1 tls sni google.com www.google.com

                route @bea500f4-0d14-431d-9bba-87ec8df540f1 {
                    proxy tcp/10.0.0.199:8443 {
                    }
                }
            }
            tls
        }
    }

    layer4 {
        import /usr/local/etc/caddy/caddy.d/*.layer4global
    }

    email твоя почта
    grace_period 10s
    import /usr/local/etc/caddy/caddy.d/*.global
}

# Reverse Proxy Configuration


# Layer4 default HTTP port
:80 {
}
# Layer4 default HTTPS port
:443 {
}

xray.home.arpa {
    tls /usr/local/etc/caddy/certificates/690295ae86977.pem /usr/local/etc/caddy/certificates/690295ae86977.key {
    }

    handle {
        @f376fb8a-12fa-4dd2-a249-7b9b93e20e38 {
            not client_ip 192.168.1.0/24
        }
        handle @f376fb8a-12fa-4dd2-a249-7b9b93e20e38 {
            abort
        }

        reverse_proxy 10.0.0.199:2053 {
        }
    }
}

opnsense.home.arpa {
    tls /usr/local/etc/caddy/certificates/690295ae86977.pem /usr/local/etc/caddy/certificates/690295ae86977.key {
    }

    handle {
        @f376fb8a-12fa-4dd2-a249-7b9b93e20e38 {
            not client_ip 192.168.1.0/24
        }
        handle @f376fb8a-12fa-4dd2-a249-7b9b93e20e38 {
            abort
        }

        reverse_proxy https://10.0.0.1:8443 {
            transport http {
                tls_insecure_skip_verify
            }
        }
    }
}

import /usr/local/etc/caddy/caddy.d/*.conf
</code></pre></div></div>

<p>Тут есть несколько моментов в конфиге Caddy. Первый 10.0.0.199 это адрес, который был выдан для xray сервиса на DHCP сервере OPNSense, для внутренних сервисов я не стал отключать DHCP, но зафиксировал lease для данного xray сервера как статический. 10.0.0.199:8443 на этом порту слушает VLESS+Reality сервер, для соединения клиентов, когда я создавал Inbound в 3x ui xray, я задал этот порт. Обратите внимание, что этот порт разрешен в firewall автоматическими правилами, так как админка на него переключена, если порт будет другой, нужно будет специально разрешить его на LAN интерфейсе. Сама же админка доступна на xray сервере в LXC контейнере на 10.0.0.199:2053. Все эти настройки делаются из OPNSense WebUI после установки плагина, конфиг руками писать не нужно. Да, не забудьте не только включить Cadyy плагин в OPNSense, но и разрешить L4 Proxy в нем. При этом обратите внимание на access `not client_ip 192.168.1.0/24`, это запретит доступ к данным локальным доменам из интернета, я не стал его открывать. Иначе знатоки подменят параметры запроса и получат возможность ковырять админку, но нужно домен угадать будет.</p>

<p>Суть проксирования в том, что Caddy опознает на L4 Proxy SNI google.com и тогда передаст сырой поток данных на xray сервер без всякого reverse proxy. После этого если xray сервер опознает VLESS коннект, то он будет работать с ним, если нет, то он прикинется google.com причем со всеми сертификатами и прочим, неотличимо. Если же Caddy не опознает SNI, то начнет работать reverse proxy и если домен будет опознан как xray.home.arpa, то xray сервер админка будет показана, а для домена opensense.home.arpa будет показана админка OPNSense. Caddy тут хороша тем, что очень просто настраивается.</p>

<p>Теперь Caddy работает на 443 и 80 портах на всех интерфейсах, а админку OPNSense мы перенесли на 8443 порт, так что заходить в нее можно пока на любом порту и будет работать.</p>

<p>Далее переходим к настройками VPS у провайдера, это наш внешний сервер, через который мы предоставим к домашним сервисам доступ из интернета. Тут все довольно просто, ставим на него ubuntu 25.04, делаем стандартные настройки безопасности вроде доступа к ssh по ключам и создания пользователя что бы не под root работать и ставим сеть.</p>

<p>Настройки сети такие. Первое нам нужно установить wireguard на VPS. Он нам нужен что бы обеспечить связь с домашним сервером где OPNSense. Конфиг wireguard такой:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@gateway:~$ sudo cat /etc/wireguard/wg0.conf
[Interface]
Address = 10.100.0.1/30
PrivateKey = это приватный ключ на VPS сервере сделанный
ListenPort = 51820

[Peer]
PublicKey = это публичный ключ, сделанный на OPNSense
AllowedIPs = 10.100.0.2/32, 10.0.0.0/24 
</code></pre></div></div>

<p>В конфиге Wireguard разрешен OPNSense, он клиент для VPS с адресом внутри тоннеля wg0 10.100.0.2/32, а так же для прохождения по тоннелю разрешены пакеты от сети 10.0.0.0/24 это пакеты от сервисов внутри моста service, который изолирует их в нашей внутренней сети. То есть наш xray сервер в итоге должен иметь возможность напрямую обратиться к VPS через этот тоннель как шлюзу 10.100.0.1.</p>

<p>Когда Wireguard на VPS настроен, нужно настроить nftables нам нужно делать NAT для пакетов, которые пришли от внутренних сервисов, например xray и делать NAT для пакетов, которые пришли в VPS и ушли через wg0 внутренним сервисам. То есть VPS будет маскировать как пакеты снаружи внутрь, так и пакеты изнутри наружу. Это, на мой взгляд самая простая конструкция из возможных. Вот настройки nftables:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>i3draven@gateway:~$ cat /etc/nftables.conf 
#!/usr/sbin/nft -f

flush ruleset

define WAN_IF = "eth0"
define WG_IF = "wg0"
define WEB_SERVER_IP = 10.100.0.2

table ip filter {
    chain LOG_AND_DROP {
        # Можно посмотреть sudo dmesg что блокировалось
        log prefix "[NFTABLES DROP] " level info counter
        drop
    }

    chain INPUT {
        type filter hook input priority filter; policy drop;
        ct state established,related accept

        # Явно разрешаем весь входящий трафик из туннеля от OPNsense
        # Это для того что бы ответные ACK пакеты просто принимались
        iifname $WG_IF accept

        iifname "lo" accept
        ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable } accept
        # Это SSH на порту таком
        tcp dport 8113 ct state new limit rate 5/minute accept
        # Это wireguard
        udp dport 51820 accept
        jump LOG_AND_DROP
    }

    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        ct state established,related accept
        # Прокидываем трафик на Caddy
        iifname $WAN_IF ip daddr $WEB_SERVER_IP tcp dport { 80, 443 } accept        
        iifname $WAN_IF ip daddr $WEB_SERVER_IP udp dport { 80, 443 } accept # Добавим UDP для Xray
        # Разрешить трафику из VPN-сети выходить в интернет
        iifname $WG_IF oifname $WAN_IF accept
        
        jump LOG_AND_DROP
    }

    chain OUTPUT {
        type filter hook output priority filter; policy accept;
    }
}

table ip nat {
    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        # DNAT трафика из интернета на OPNsense
        iifname $WAN_IF tcp dport { 80, 443 } dnat to $WEB_SERVER_IP
        iifname $WAN_IF udp dport { 80, 443 } dnat to $WEB_SERVER_IP # И для UDP
    }

    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname $WAN_IF masquerade
        oifname $WG_IF masquerade
    }
}
</code></pre></div></div>

<p>Вот это правило `iifname “eth0” tcp dport { 80, 443 } dnat to 10.100.0.2` пробрасывает все, что пришло из интернета на OPNSense, где слушает Caddy что бы определить какой из внутренних сервисов будет отвечать на запрос. Тем самым во внутренней сети мы можем по доменам иметь сколько угодно чего угодно. Вот эти два правила `oifname “eth0” masquerade`, `oifname “wg0” masquerade` как раз и занимаются маскировкой трафика.</p>

<p>Думаю это достаточно безопасная настройка nftables, если имеете советы по улучшению, было бы отлично. Но что бы не смотреть на постоянные сканирования портов я на firewall провайдера запретил все порты, кроме тех, что нужны, то есть ssh, wireguard, http, https. В противном случае ядро вашей VPS постоянно будет ловить пакеты от сканеров.</p>

<p>Настройка Wireguard невозможна с одной стороны, нужен как минимум публичный ключ от OPNSense, так что параллельно нужно настроить в OPNSense Wireguard. Я покажу часть конфигурации OPNSense с итогами, но настраивается все как и раньше в WebUI:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;wireguard&gt;
      &lt;client version="1.0.0" persisted_at="1762127854.97"&gt;
        &lt;clients&gt;
          &lt;client uuid="b0b854fc-86fc-4210-b11c-d9c0e419cb43"&gt;
            &lt;enabled&gt;1&lt;/enabled&gt;
            &lt;name&gt;frontend_peer&lt;/name&gt;
            &lt;pubkey&gt;key&lt;/pubkey&gt;
            &lt;psk/&gt;
            &lt;tunneladdress&gt;0.0.0.0/0&lt;/tunneladdress&gt;
            &lt;serveraddress&gt;addr VPS server&lt;/serveraddress&gt;
            &lt;serverport&gt;51820&lt;/serverport&gt;
            &lt;keepalive&gt;25&lt;/keepalive&gt;
          &lt;/client&gt;
        &lt;/clients&gt;
      &lt;/client&gt;
      &lt;general version="0.0.1" persisted_at="1762133000.75"&gt;
        &lt;enabled&gt;1&lt;/enabled&gt;
      &lt;/general&gt;
&lt;server version="1.0.1" persisted_at="1762127854.97"&gt;
        &lt;servers&gt;
          &lt;server uuid="99ddd6b8-baea-4856-abe3-ce3e65a3e235"&gt;
            &lt;enabled&gt;1&lt;/enabled&gt;
            &lt;name&gt;frontend&lt;/name&gt;
            &lt;instance&gt;0&lt;/instance&gt;
            &lt;pubkey&gt;key&lt;/pubkey&gt;
            &lt;privkey&gt;key&lt;/privkey&gt;
            &lt;port&gt;51820&lt;/port&gt;
            &lt;mtu/&gt;
            &lt;dns/&gt;
            &lt;tunneladdress&gt;10.100.0.2/32&lt;/tunneladdress&gt;
            &lt;disableroutes&gt;1&lt;/disableroutes&gt;
            &lt;gateway/&gt;
            &lt;carp_depend_on/&gt;
            &lt;peers&gt;b0b854fc-86fc-4210-b11c-d9c0e419cb43&lt;/peers&gt;
            &lt;debug&gt;1&lt;/debug&gt;
            &lt;endpoint/&gt;
            &lt;peer_dns/&gt;
          &lt;/server&gt;
        &lt;/servers&gt;
      &lt;/server&gt;
    &lt;/wireguard&gt;
</code></pre></div></div>

<p>Тут нужно распутаться с тем куда какие ключи поставить, но думаю разберетесь, публичный ключ OPNSense в итоге попадет в конфиг wg0.conf на VPS. Но самое главное тут в том, что при создании Wireguard Instance в OPNSense нужно обязательно указать опцию `<strong>Disable routes</strong>` это очень важно потому, что при дальнейших настройках связь с OPNSense без нее пропадет. И лишь после запрета создания routes можно сделать peer в котором в качестве allowed ips указать 0.0.0.0/0. Если маршруты создавать не запретить, то будет создан маршрут, который завернет весь трафик в Wireguard.</p>

<p>На вский случай, в Proxmox можно открыть консоль VM или контейнера и там все вернуть. Для OPNSense есть в консоли пункт 13 в меню, который позволяет вернуть бэкап настроек, каждый раз когда вы меняете настройки текущее состояние бэкапиться. Но я рекомендую просто делать snapshot в Proxmox и все, потом легко вернуть снимок виртуалки в состояние до опасных изменений.</p>

<p>Итак, в этот момент у нас есть настроенный тоннель от OPNSense до VPS, но нет маршрутов, так что он пока не работает. Нам нужно сделать еще несколько вещей. Первая это зайти в OPNSense-&gt;Interfaces-&gt;Assignments и добавить новый Wireguard VPN интерфейс в OPNSense с любым подходящим именем, я назвал WGVPS.</p>

<p>Так же нужно зайти в свойста OPNSense-&gt;Interfaces-&gt;{LAN, WAN, WGVPS} и снять галочку ` Block private networks` у всех этих интерфейсов. Это уберет автоматически создаваемые правила firewall, которые заблокируют нам трафик через Wireguard тоннель. Это важный момент.</p>

<p>Потом нужно зайти в настройки WGVPS и поставить галочку `Dynamic gateway policy` это приведет к тому, что для динамического интерфейса WGVPS OPNSense создаст шлюз в OPNSense-&gt;System-&gt;Gateways-&gt;Configuration. Это важный момент, шлюз надо создать именно таким способом, не руками, так как VPN динамический интерфейс.</p>

<p>Когда шлюз создан мы можем перейти к настройкам firewall, вот конфиг:</p>

<p>Скрытый текст</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;filter&gt;
&lt;rule uuid="631de44f-3131-4677-bc6e-7a0a0e72828f"&gt;
      &lt;type&gt;reject&lt;/type&gt;
      &lt;interface&gt;wan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Block LAN to OpenWRT internet&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;source&gt;
        &lt;network&gt;lan&lt;/network&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;any&gt;1&lt;/any&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762217647.61&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762217620.69&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;

    &lt;rule uuid="f6cf4478-9e1d-415e-b54f-6d42d0c0b264"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;wan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Acces from LAN to Firewall UI&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;tcp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;address&gt;192.168.1.0/24&lt;/address&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;address&gt;(self),wanip&lt;/address&gt;
        &lt;port&gt;443&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762086725.77&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761839287.60&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
   &lt;rule uuid="772ecf5a-9d0e-48e2-a8c5-7dcf448cf530"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;wan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Acces from LAN to Firewall UI&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;tcp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;address&gt;192.168.1.0/24&lt;/address&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;address&gt;(self),wanip&lt;/address&gt;
        &lt;port&gt;8443&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761927485.66&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761927485.66&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
  &lt;rule uuid="70b3f1f0-8563-4ebe-b74f-d7f806abaa40"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;wan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Acces from LAN to Firewall UI&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;tcp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;address&gt;192.168.1.0/24&lt;/address&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;address&gt;(self),wanip&lt;/address&gt;
        &lt;port&gt;22&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761848634.17&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761846900.64&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
    &lt;rule uuid="dc0e35d2-53bb-4987-9dfa-60455761f285"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;wan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Access from LAN to DNS&lt;/descr&gt;
      &lt;direction&gt;out&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;udp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;any&gt;1&lt;/any&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;any&gt;1&lt;/any&gt;
        &lt;port&gt;53&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761850932.61&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@192.168.1.132&lt;/username&gt;
        &lt;time&gt;1761850932.61&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;

&lt;rule uuid="e1bf2add-31eb-4b58-b824-35fbc9c0401d"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;lan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Allow services to reply to proxy and use DNS&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;source&gt;
        &lt;network&gt;lan&lt;/network&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;network&gt;(self)&lt;/network&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762560063.06&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762560063.06&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;

 &lt;rule uuid="9c026114-0bcb-4419-8cbf-64f256f33bfc"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;lan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;All LAN traffic to VPS&lt;/descr&gt;
      &lt;gateway&gt;WGVPS_GW&lt;/gateway&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;source&gt;
        &lt;network&gt;lan&lt;/network&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;address&gt;Local_networks&lt;/address&gt;
        &lt;not&gt;1&lt;/not&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762198751.01&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762198685.98&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
&lt;rule uuid="75537f75-d7c0-4757-99fc-df2056ef06c1"&gt;
      &lt;type&gt;block&lt;/type&gt;
      &lt;interface&gt;lan&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Block access from Services to all other local networks&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;source&gt;
        &lt;network&gt;lan&lt;/network&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;address&gt;Local_networks&lt;/address&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762560079.99&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
    &lt;/rule&gt;



&lt;rule uuid="c404dacf-7834-4c79-b186-c20654166929"&gt;
      &lt;type&gt;block&lt;/type&gt;
      &lt;interface&gt;opt1&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Block VPS internet to WAN&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;source&gt;
        &lt;any&gt;1&lt;/any&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;network&gt;wan&lt;/network&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762218108.82&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1762217998.73&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;

   &lt;rule uuid="34289115-42de-41e4-afb9-db2835ebed67"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;opt1&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Allow WG to Caddy HTTPS&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;tcp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;any&gt;1&lt;/any&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;network&gt;(self)&lt;/network&gt;
        &lt;port&gt;443&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1761938713.37&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1761937442.75&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
  &lt;rule uuid="5d4cb405-c3d4-491b-a185-6cd5121ece4b"&gt;
      &lt;type&gt;pass&lt;/type&gt;
      &lt;interface&gt;opt1&lt;/interface&gt;
      &lt;ipprotocol&gt;inet&lt;/ipprotocol&gt;
      &lt;statetype&gt;keep state&lt;/statetype&gt;
      &lt;descr&gt;Allow WG to Caddy HTTP&lt;/descr&gt;
      &lt;direction&gt;in&lt;/direction&gt;
      &lt;quick&gt;1&lt;/quick&gt;
      &lt;protocol&gt;tcp&lt;/protocol&gt;
      &lt;source&gt;
        &lt;any&gt;1&lt;/any&gt;
      &lt;/source&gt;
      &lt;destination&gt;
        &lt;network&gt;(self)&lt;/network&gt;
        &lt;port&gt;80&lt;/port&gt;
      &lt;/destination&gt;
      &lt;updated&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1761938704.08&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/updated&gt;
      &lt;created&gt;
        &lt;username&gt;root@10.0.0.1&lt;/username&gt;
        &lt;time&gt;1761938704.08&lt;/time&gt;
        &lt;description&gt;/firewall_rules_edit.php made changes&lt;/description&gt;
      &lt;/created&gt;
    &lt;/rule&gt;
  &lt;/filter&gt;
</code></pre></div></div>

<p>Приводить правила из самого pf с консоли я не стал потому, что они там не будут иметь названий и их намного больше, там много автоматических. Это те, что я настраивал из OPNsense Web UI, цитата из его конфига. Правила разрешают доступ по HTTP HTTPS к Caddy, который слушает на этих портах что бы было доступ к админке OPNSense и прочему. Возможно часть правил нужно создать в самом начале что бы доступ открыть, разберетесь.</p>

<p>Самое важное правило `All LAN traffic to VPS` именно оно заставляет все LAN сервисы ходить в интернет через VPS. Без него клиенты, подключенные к xray будут видеть ваш домашний IP так как сервисы будут ходить в интернет через домашний роутер! Там список Local networks это просто OPNSense-&gt;Firewall-&gt;Aliases и там я сделал имя для `192.168.1.0/24`,`192.168.2.0/24`,`10.0.0.0/24` сетей.</p>

<p>Наконец, последний этап, мы отказались от создания маршрутов для Wireguard через опцию `Disable routes`, так что нужно добавить маршрут в сеть за wg0 руками. Это делается в OPNSense-&gt;System-&gt;Routes-&gt;Configuration. Создаем маршрут для сети 10.100.0.0/24 (это сеть для Wireguard) и в качестве шлюза выбираем автоматически созданный для Wireguard шлюз, у меня он называется WGVPS_GW. Если бы мы шлюз руками сделали, это не сработало бы, нужен именно динамически создаваемый шлюз. Иначе маршрут сделать можно, но он только в интерфейсе будет показан, но не применится. В итоге пакеты, которые летят от xray в wg0 будут знать куда лететь.</p>

<p>При этом если бы мы не сделали `Disable routes` и 0.0.0.0/0 как разрешенные адреса для Wireguard, то у меня никаким способом не получилось завернуть трафик так что бы все внутренние сервисы из LAN ходили в интернет через VPS, а не через домашний роутер. Оставлять им выход через домашний роутер я посчитал небезопасным. Может быть кто-то в комментариях предложит лучший способ. Не безопасно это потому, в том числе, что любой клиент видит ваш домашний IP.</p>

<p>Что мы имеем в итоге:</p>

<ol>
  <li>
    <p>Доступ ко всем админкам, Proxmox, OPNSense, 3x UI xray, есть через связку OPNSense+Caddy. Доступ только из локальной сети.</p>
  </li>
  <li>
    <p>Есть тоннель между OPNSense и VPS за счет которого все внутренние сервисы могут ходить в интернет и быть видны в интернете за счет NAT на VPS.</p>
  </li>
  <li>
    <p>Локальные сервисы тем не менее могут ходить на разрешенные порты в локальной сети, например 53 порт для DNS. В целом, настройки DNS DHCP я опустил, они не обязательны, но конечно к VPS можно прикрепить доменное имя. Так же обратите внимание, что xray генерирует ссылку подключения с неверным доменом, он берет его из браузера судя по всему, а нужно будет там поставить домен VPS и порт 443, который является фронтом для наших домашних сервисов.</p>
  </li>
  <li>
    <p>Коннект к VPS выглядит так будто на нем стоит xray, но на деле он на домашнем сервере, с бесплатными бэкапами, относительно провайдерского безграничным диском и так далее. То есть в данную конструкцию легко добавить и другие сервисы, просто добавляя домены в Caddy и при этом сохранив xray как скрытый сервис.</p>
  </li>
</ol>

<p>Задачи со звездочкой:</p>

<ol>
  <li>
    <p>Настроить на OpenWrt QoS что бы регулировать ширину канала для дома и сервисов.</p>
  </li>
  <li>
    <p>Применить Lynis или аналоги для сканирования безопасности.</p>
  </li>
  <li>
    <p>Поставить среди сервисов автоматический сканер безопасности, который будет предупреждать если вы случайно накосячите.</p>
  </li>
  <li>
    <p>Подумать как сделать эту конструкцию с помощью snat (output nat) на OPNSense. Может будет удобнее.</p>
  </li>
  <li>
    <p>Специально для снижения пинга в играх может конкретно xray перенести на саму vps, изучить возможности реверс прокси у xray.</p>
  </li>
  <li>
    <p>Добавить на VPS второй внешний IP адрес чтобы на него перенести ssh и wireguard для большей маскировки. Тогда на адресе с xray останется только “сайт” под который вы замаскировались.</p>
  </li>
  <li>
    <p>Для экономных, если смириться с тем, что будет терминация https трафика и обойтись без xray vless+reality, то собственную VPS можно заменить на бесплатный Cloudflare tunnel или аналог.</p>
  </li>
  <li>
    <p>Для нервных: можно на уровне proxmox моста services порезать мост на VLAN-ы и сделать opnsense для них шлюзом, для полной изоляции сервисов на случай взлома.</p>
  </li>
  <li>
    <p>В Unbound DNS в OPNSense для LAN сети где xray включить блоклисты на DNS. Это позволит резать рекламу и шпиенское ПО, ладно бы мешало, оно сильно садит батарею телефона, очень вероятно он будет работать раза в два дольше от этого, если вы подключитесь к личному xray и правильно настроите NAT что бы все запросы от клиентов на 53 порт DNS шли через ваш Unbound DNS.</p>
  </li>
</ol>

<p>P.S. Если будут рекомендации по улучшению, буду признателен, между подобными заходами покрутить что-то у меня бывает годы проходят и я успеваю забыть детали. При этом OPNSense, xray я и вовсе настраивал впервые. Да, и возможно я найду какие-то проблемы в настройках позже, написал пока не забыл :) В общем советы приветствуются. Так же хочу напомнить о сайтах типа <a href="https://ipleak.net/">такого</a>, которые при работающем xray позволяют посмотреть утекают ли ваши адреса в сеть при открытии сайтов и например включить DNS в 3x-ui, а так же предпринять другие меры безопасности.</p>

<p>UPD1: Задача 4 и 9 сделаны. После 4 впн стал быстрее намного работать. Для 9 я так же добавил NAT rdr правило что бы все, что даже напрямую лезет куда угодно на 53 порт, попадало в мой Unbound DNS, а то есть гора скрытых хитрецов.</p>

<p>UPD2: Подумалось, что стоило упомянуть. Так как в статье предполагается, что внутренние сервисы находятся в одной изолированной сети services, трафик от caddy до сервисов должен быть https , что бы взлом одного из ваших сервисов не привел к перехвату данных соседних совсем уж просто. Ну, либо п.8 из задач со звездочкой становится обязатнльным. В следующей статье поговорим о безопасности побольше.</p>

<p>UPD3: Нужно отметить, что в современных условиях wireguard как средство связи между домашним сервером и VPS может быть не надежным. Но суть конструкции в том, что мы организуем связь домашнего сервера OPNSense как клиента к внешнему серверу VPS. Само же средство связи может быть любым. Например можно в сети services сделать VM/LXC в котором будет xray vless+reality клиент к серверу xray, установленному на VPS. Это усложнит конструкцию, но не поменяет ее суть. Просто маршрутизация внутри домашнего сервера будет не “внутренний сервис в сети services”-&gt;OPNSense-&gt;Wireguard-&gt;VPS-&gt;Internet, а “внутренний сервис в сети services”-&gt;OPNSense-&gt;VM/LXC xray клиент-&gt;OPNSense-&gt;домашний роутер-&gt;VPS-&gt;Internet, где xray клиент-&gt;сервер вместо wireguard-&gt;VPS. При этом xray в статье выбран только потому, что он позволяет продемонстрировать что такое L4 Proxy. Все остальное дело изобретательности читателя.</p>

<p>UPD4: Для тех кому l4 proxy не нужен, можно взглянуть на проект <a href="https://Pangolin">https://github.com/fosrl/pangolin</a></p>

<p>UPD5: Для ограничения доступа к админским ресурсам через caddy обратите внимание на аутентификацию через клиентский сертификат. Его можно сгенерировать прямо в OPNSense и после этого использовать в клиентском браузере и caddy. Очень удобно так как браузер о сертификате спросит только один раз и при этом у вас есть защищенная админка очередного дырявого без такой защиты, написанного черти кем и как сервиса, который тем не менее вам нужен :)</p>]]></content><author><name></name></author><category term="Proxmox" /><category term="opensense" /><category term="xray" /><category term="3x-ui" /><category term="vps" /><category term="домашний сервер" /><summary type="html"><![CDATA[Как‑то я писал статью «Личное облако на Proxmox» где описывал как установить Proxmox разными способами и в целом, что это такое, обещал, что когда то, под настроение опишу продолжение про сетевую часть домашнего облака с сервисами, доступными из интернет на домашнем сервере, вот, пожалуйста Прошлая статья была посвящена базовой настройке. Что сейчас я имею в итоге, немного изменив то, что делал я получил:]]></summary></entry><entry><title type="html">История одного саббатикла</title><link href="https://3draven.github.io/blog/2024/04/21/sabbatical-story.html" rel="alternate" type="text/html" title="История одного саббатикла" /><published>2024-04-21T15:57:11+00:00</published><updated>2024-04-21T15:57:11+00:00</updated><id>https://3draven.github.io/blog/2024/04/21/sabbatical-story</id><content type="html" xml:base="https://3draven.github.io/blog/2024/04/21/sabbatical-story.html"><![CDATA[<p>Было время когда я решил, что сидеть за компьютером все время как-то нехорошо и надо что то руками сделать. Это было давно и в итоге я спроектировал, и построил установку горизонтально-направленного бурения. С тех пор прошло много лет и я решил отдать все материалы по этой установке в общественное пользование. Эта статья доказывает, что и руками я могу делать вещи странные и непонятные :)</p>

<hr />

<p>Прошло много лет с тех пор как этот проект был мной сделан, так что я решил опубликовать материалы с ним связанные может кому пригодятся. <a href="https://mega.nz/file/ZVlklAiZ#kXZuQosQZFo6Sd_lP6lZFdQcqNtz5akoZ_zUcOVMbmI">Тут</a> находятся материалы, которые были использованы для конструирования установки горизонтально-направленного бурения (ГНБ или HDD) на основе проколов грунта. Там же учебники, модели и прочие материалы, которые так или иначе мне пригодились при этом. Мой старый друг говорил, что надо все это запатентовать, но не думаю, что это того стоит. Когда-то давно я спроектировал и собрал на заводе, и в гараже, эту установку. На данный момент это для меня давно пройденный этап, но было любопытно. В то время аналоги, которые были по всем параметрам хуже, стоили больше миллиона рублей. Так выглядело железо с проекта в работе :)</p>

<p><img src="/blog/assets/images/sabbatical-story/01.jpg" alt="ГНБ установка" title="ГНБ установка" /></p>

<p>ГНБ установка</p>

<p>Часть чертежей и моделей в самом начале я делал в Компас 3D, но оказалось удобнее делать все в Solid Works, он умел делать прочностные расчеты. Помимо всех чертежей первой версии установки тут есть еще и модель-заготовка второй версии все сделано в Solid Works, вторая версия учитывала накопленный опыт и была на мой взгляд перспективной.</p>

<p>Вторая была легче и более гибко настраивалась, но до испытаний так и не дошло я передумал продолжать развивать этот проект так как в России строительство это опасное занятие по многим причинам в том числе потому, что нет планов коммуникаций и всегда есть риск повредить что-то, что просто никому не известно под землей. В целом проект был просто передышкой и личным экспериментом, который затянулся на два года и был успешен. Первая версия до сих пор наверное где-то работает, мы ее продали и я потом год просто жил у моря, осваивал получше программирование под Android :) Работал с ней я конечно не один. Кстати, в планах была и третья версия. Дело в том, что производить железо в РФ совершенно не выгодно по многим причинам. Первая была просто мощнее и надежнее чем у всех, что давало нам большое преимущество, но третья должна была просто быть вне конкуренции, да, мечты, мечты :)</p>

<p>Самой установки мало и была некоторая оснастка, которая была сделана помимо этого. В частности нужна гидравлическая маслостанция. В итоге было придумано использовать двигатели от обычных “Москвичей” и подобных машин со свалки, включая коробку передач. Это позволило сделать маслостанцию с контролем нагрузки буквально за 15тыс рублей. Когда двигатель выходил из строя, такое было пару раз, мы просто увозили его обратно на свалку и сдавали на металл, покупая там же очередной старый с разбора, вообще не ремонтируя их.</p>

<p>Может кому пригодится метод, так как это мало того, что экономит несколько сотен тысяч рублей от покупки дизельной маслостанции, так еще и экономит массу денег на ремонте. В разделе с чертежами можно найти чертеж проставки для маслостанции это просто адаптер, позволяющий подключить гидронасос к коробке передач “Москвича”. Сборку маслостанции мне помог осуществить один очень хороший и умный механик.</p>

<p>Смешно было когда я посчитал необходимый крутящий момент и вроде бы двигатель подходил, но не подумал, что это крутящий момент не на старте его работы, так что пришлось вернуть в конструкцию коробку передач, что потом оказалось очень верным решением по многим причинам. Денег на изменения конструкции уже не было :)</p>

<p>Для маслостанции я сначала собрал “круизконтроль” на ардуино, что бы при повышении нагрузки он автоматически давил на газ, но оказалось, что удобнее просто переключать скорости на каробке передач когда надо и крузиконтроль был отключен. Но было любопытно собрать плату, написать к ней софт и прочее. Я чуть было не начал изобретать и локатор к этому всему, это спец-штука для определения положения буровой головы под землей, но одумался так как это долго, хоть я и придумал кажется решение лучше современных аналогов. Правда это без реализации мои фантазии конечно.</p>

<p>Помимо этого в папке PаthControl можно найти исходники приложения для телефона, если я то скопировал, давно было. Это приложение позволяет просчитывать траекторию прокола так что бы оборудование не сломать. Приложение простое, поначалу я сконструировал свой сплайн в Wolfram с особыми свойствами, но потом оказалось, что для практики достаточно обычных кривых Безье и соблюдения условий на кривизну траектории прокола в ключевых точках, собственно в приложении так и сделано.</p>

<p>Некоторое время приложение было опубликовано в Google Play но со временем устарело и было снято с публикации, так что кому интересно сами соберете, оно наколеночное и писалось мной очень и очень давно, уже десять лет тому назад наверное. Суть приложения в том что бы задать таблицу из точек в метрах (если я верно помню) где прокол должен изгибаться и после этого посмотреть параметры получившейся кривой, не выходят ли они за допустимые для штанг нормы. Нормы брались из спецификаций штанг, которые продавались в то время. Но штанги из приведенных в архиве чертежей выдерживают в несколько раз большие нагрузки, так что никогда не ломались.</p>

<p>P.S. Все материалы за моим авторством я опубликовал без каких бы то ни было гарантий и ответственности, как есть, вы можете делать с ними что угодно :)</p>

<p>P.P.S. В России не живу уже довольно много лет и этот проект просто повод для баек сегодня, вспоминаю с теплотой.</p>]]></content><author><name></name></author><category term="гнб" /><category term="саббатикал" /><summary type="html"><![CDATA[Было время когда я решил, что сидеть за компьютером все время как-то нехорошо и надо что то руками сделать. Это было давно и в итоге я спроектировал, и построил установку горизонтально-направленного бурения. С тех пор прошло много лет и я решил отдать все материалы по этой установке в общественное пользование. Эта статья доказывает, что и руками я могу делать вещи странные и непонятные :)]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/sabbatical-story/cover.jpg" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/sabbatical-story/cover.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Личное облако на Proxmox</title><link href="https://3draven.github.io/blog/2024/03/02/proxmox-personal-cloud.html" rel="alternate" type="text/html" title="Личное облако на Proxmox" /><published>2024-03-02T18:02:41+00:00</published><updated>2024-03-02T18:02:41+00:00</updated><id>https://3draven.github.io/blog/2024/03/02/proxmox-personal-cloud</id><content type="html" xml:base="https://3draven.github.io/blog/2024/03/02/proxmox-personal-cloud.html"><![CDATA[<p>В прошлой статье (<a href="/blog/2024/02/18/orangepi5-proxmox8.html">OrangePi 5 с Proxmox 8</a>) я писал как поставить Proxmox 8 на Orange Pi 5(b). Это даже получилось, но я уперся в то, что производитель железки не выпускает свежие ядра под нее. Для железки есть 5.10.160 и оно устарело для Proxmox 8. Виртуалки работают, но есть проблемы с фаерволом. Производитель обещает вот-вот выпустить новое, но ждать я не стал и решил сделать инфраструктуру по другому, позже докину ноды на арме просто. О чем и напишу. Получается уже цикл статей. Эта будет описывать цель и первые шаги к ней.</p>

<hr />

<p>Итак, на чем я решил строить инфраструктуру домашнего облака:</p>

<ol>
  <li>
    <p>Intel NUC. Это спека по которой выпускают компактные компьютеры. Купил Asus Mini PC.</p>
  </li>
  <li>
    <p>Док станция для двух 2.5-3.5 HDD, которая умеет работать со скоростью 10Gbps по USB 3.2</p>
  </li>
  <li>
    <p>Два HDD 3.5 на 8Тб. Что мелочиться.</p>
  </li>
  <li>
    <p>С Orange PI 5b снаял два SDD на 2Тб</p>
  </li>
  <li>
    <p>Докупил еще один NVME SDD на 2Тб что бы внутрь NUC-a воткнуть.</p>
  </li>
  <li>
    <p>Бесперебойник для этой всей красоты.</p>
  </li>
</ol>

<p>Выглядит все это вот так:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/01.jpg" alt="" /></p>

<p>Идея такова:</p>

<ol>
  <li>
    <p>Поставить на NUC Proxmox 8, в нем сделать набор виртуалок и на них построить все свои сервисы.</p>
  </li>
  <li>
    <p>Приобрести за 4$ в месяц минимальный внешний сервер в облаке что бы через него выставить наружу свои сервисы.</p>
  </li>
</ol>

<p>Сетевая инфраструктура будет такова:</p>

<ol>
  <li>
    <p>Внешний сервер будет pfSense c Wireguard VPN. Я вообще имею статью (<a href="/blog/2023/08/22/phone-battery-vpn-dns.html">Как продлить время автономной работы телефона (VPN+DNS)</a>) о том как используя Ansible playbook сделать себе VPN, но это мне нужно было что бы Ansible и IPTables освоить ну и с сетями получше разобраться. Для реального же использования рекомендую pfSense. Он может все на свете, что можно придумать в области VPN, фаерволов и прочего. Плюс удобен и отлично документирован. Не говоря уже о высочайшем качестве.</p>
  </li>
  <li>
    <p>Внутри Proxmox из виртуалок будет сделано две изолированных локальных сети. Изолированных от всего на свете. Одна под личные, вторая под публичные сервисы. Это будут просто два сетевых моста, которые ни к чему не привязаны.</p>
  </li>
  <li>
    <p>Так же внутри будет виртуалка с pfSense, который будет видеть две эти локалки и WAN интерфейс (выход в интернет). Решено пока не морочить голову с VLAN так как сеть простая. Он будет гейтом и будет по VPN соединен с внешним pfSense для публикации сервисов.</p>
  </li>
  <li>
    <p>На внутреннем pfSense будет поднят HAProxy что бы публиковать сервисы из виртуалок на порту. На поддоменах.</p>
  </li>
  <li>
    <p>На внешнем pfSense будет проброшен порт HAProxy внутреннего (он виден в VPN) в интернет. Это позволит на внешнем сервере иметь только VPN и более ничего, а значит он будет копеечный.</p>
  </li>
  <li>
    <p>На начальном этапе на самом деле все pfSense будут подняты прямо в Proxmox, так как VPN pfSense иметь в облаке или нет, не важно пока нет внешних коннектов. Это позволит в безопасной обстановке все протестить.</p>
  </li>
</ol>

<p>Такое решение с одной стороны не требует дорогого внешнего сервера. Тут нужно отметить, что трафик тарифицируется с определенного объема и надо это учитывать. С другой позволяет легко сносить поломанные в процессе экспериментов виртуалки, делать шаблоны и снапшеты с бэкапами, виртуалок я уже поломал с десяток и обычно вместо починки просто откатываю, что время экономит. Proxmox великолепен когда работает! При этом изоляция локалок, изоляция обоих pfSense внутри VPN и хост системы позволяет не особо беспокоится над тем, что внешние сервисы будут представлять опасность для моих домашних железок.</p>

<p>Итак, начнем. Первое это установка Proxmox VE. Тут сразу есть грабля. Их iso дистр самодельный на мой взгляд шлак (мое IMHO после огребания проблем, в коментах сказали, что проблем не имеют). Там LVM не работает и при этом debian перелопачен так, что лучше не видеть. Так что попробовав его я поставил Debian Bookworm и уже в него накатил Proxmox. Процесс установки такой же как в моей прошлой статье (<a href="/blog/2024/02/18/orangepi5-proxmox8.html">OrangePi 5 с Proxmox 8</a>) Оф. дока тут (https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_12_Bookworm) и в ней рекомендуют ставить первым ядро. Мне в коментах подсказали. Я ставил его потом и проблем кажется нет, только KSM никак не проверю работает ли. За исключением того, что не надо прошивки ставить для ARM-а все на прошлую статью похоже:</p>

<p>Редактируем как там описано /etc/hosts</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>127.0.0.1       localhost
192.168.1.230   pve-main.proxmox.com pve-main

# The following lines are desirable for IPv6 capable hosts
::1     localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
</code></pre></div></div>

<p>Добавляем репозитории Proxmox в /etc/apt/source.lists</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>deb http://ftp.debian.org/debian bookworm main contrib non-free non-free-firmware
deb http://ftp.debian.org/debian bookworm-updates main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware


# Proxmox Backup Server pbs-no-subscription repository provided by proxmox.com,
# NOT recommended for production use
deb http://download.proxmox.com/debian/pbs bookworm pbs-no-subscription
deb http://download.proxmox.com/debian/pbs-client bookworm main
deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription
</code></pre></div></div>

<p>Устанавливаем aptitude, что бы если будут проблемы с зависимостями она их нашла и решила. В репах Proxmox они бывают, так что рекомендую.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt-get install aptitude
</code></pre></div></div>

<p>Отключаем NetworkManager так как PVE рулит сетью с помощью ifupdown2</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl stop NetworkManager.service
systemctl disable NetworkManager.service
</code></pre></div></div>

<p>Отключаем все suspend/hibernate штуки в debian, так как они сервер гасить будут:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
</code></pre></div></div>

<p>Ставим Proxmox VE (PVE) причем обратите внимание, что ifupdown2 ставится первым. Думаю это может быть важно.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aptitude install ifupdown2 proxmox-ve open-iscasi
</code></pre></div></div>

<p>В итоге Proxmox установится и заработает. Можно зайти в консоль по https://${host-ip}:8006, но это еще не все. Хочется красивых бэкапов, так что ставим еще Proxmox Backup Server (PBS) эта штука умеет их сжимать и дедуплицировать и еще много чего. Очень хорошая. Я поставил его прямо на тот же хост, что и PVE.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aptitude install proxmox-backup-server proxmox-backup-client
</code></pre></div></div>

<p>Клиент ставится потому, что бэкапить сам Proxmox сервер тоже хочется и он, как ни странно, сам себя прекрасно бэкапит. Теперь PBS доступен по адресу https://${host-ip}:8007, можно зайти в консоль. Бэкап самого сервера пока сделать некуда, собственно и займемся тем что бы было куда, идея такова:</p>

<ol>
  <li>
    <p>Два HDD будут собраны в один LVM Stripe 2 том, то есть их скорость будет складываться практически при записи. Это не надежно, так как если помрет один, то помрет весь раздел, но скорость мне важнее. При этом расчет сделан на то, что либо помрут HDD с бэкапами, либо помрут SDD с данными и виртуалками, не одновременно и в этом дублирование. Я не бизнес и мне бэкапы в состоянии на пять лет назад не нужны. Либо из текущих данных восстановлю бэкапы, либо из бэкапов текущие данные. Эх, давно я не слышал как работают HDD, звук похож на хаптик вибрики современные на телефонах :)</p>
  </li>
  <li>
    <p>Раздел на внутреннем SDD и два внешних SDD будут слиты в один LVM Stripe 3 том для той же цели, скорость работы. Раздел и диски одинакового размера. Это так получилось потому, что два внешних SSD на деле не 2Гб, а маркетингово обрезаны до круглых чисел. Да, тут есть грабля, не во всех USB портах SSD работали стабильно, иногда начинались тормоза и зависания пока не воткнул в те порты, что умеют USB 3.2 gen 2 + Thunderbolt 4.</p>
  </li>
  <li>
    <p>Сделанный при установке PVE LVM том /dev/pve-main-vg/root будет как раз заменен на том из п.2 Так как если просто добавить в него два SSD, то Stripe 3 не будет.</p>
  </li>
</ol>

<p>Собственно собираем два HDD в том. Далее примерно по памяти, если что тут смотреть (<a href="https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/7/html/logical_volume_manager_administration/stripe_create_ex">https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/7/html/logical_volume_manager_administration/stripe_create_ex</a>).</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pvcreate /dev/sdX /dev/sdX
vgcreate backups /dev/sdX /dev/sdX
lvcreate -i 2 -I 4 -l 100%FREE -n backups_2_stripe backups
mkfs.ext4 /etc/backups/backups_2_stripe
</code></pre></div></div>

<p>У нас в итоге есть раздел для бэкапов, который пашет с практически их суммарной скоростью. Этим и многим другим LVM прекрасен. Я не стал мудрить с ZFS и Btrfs хоть они и имеют нечто похожее потому, что LVM проще, плюс у Proxmox есть интеграция с ним, хотя и с FS она есть, не пробовал.</p>

<p>Далее наступает момент преобразования /dev/pve-main-vg/root в Stripe 3 том. Это не обязательно, просто хочется. Сначала я сделал загрузочную флешку с Ubuntu 22.04 live и сделал все с нее (для справки отлично описано тут <a href="https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/7/html/logical_volume_manager_administration/stripe_create_ex">https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/7/html/logical_volume_manager_administration/stripe_create_ex</a>):</p>

<ol>
  <li>
    <p>Монтируем /dev/pve-main-vg/root в /mnt/root</p>
  </li>
  <li>
    <p>Монитруем /dev/backups/backups_2_stripe в /mnt/backups</p>
  </li>
  <li>
    <p>Делаем в /mnt/backups папку /mnt/backups/root_copy</p>
  </li>
  <li>
    <p>Копируем весь рут в папку rsync -avh /mnt/root/ /mnt/backups/root_copy/ команда по памяти, гуглите.</p>
  </li>
  <li>
    <p>Отмонтируем все.</p>
  </li>
  <li>
    <p>Сносим /dev/pve-main-vg/root командой lvremove /dev/pve-main-vg/root он спросит в своем ли ты уме, отвечаем, что кажется да.</p>
  </li>
  <li>
    <p>Сносим так же /dev/pve-main-vg/swap_1 это просто подкачка и она потом все равно будет нужна, но для чистоты эксперимента.</p>
  </li>
  <li>
    <p>Добавляем SSD диски в volume group: vgextend pve-main-vg /dev/sdX &amp;&amp; vgextend pve-main-vg /dev/sdX</p>
  </li>
  <li>
    <p>Делаем новый striped LVM logical volume: <code class="language-plaintext highlighter-rouge">lvcreate -i 3 -I 4 -l 100%FREE -n root</code>pve-main-vg</p>
  </li>
  <li>
    <p>Создаем FS: mkfs.ext4 /dev/pve-main-vg/root</p>
  </li>
  <li>
    <p>Далее уменьшаем этот root на 1Гб (так мне проще, отлично описано тут <a href="https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/8/html/configuring_and_managing_logical_volumes/modifying-the-size-of-a-logical-volume_configuring-and-managing-logical-volumes">https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/8/html/configuring_and_managing_logical_volumes/modifying-the-size-of-a-logical-volume_configuring-and-managing-logical-volumes</a>) что бы подкачку впихнуть: lvreduce –resizefs -L -1G pve-main-vg/root</p>
  </li>
  <li>
    <p>Создаем подкачку: <code class="language-plaintext highlighter-rouge">lvcreate -i 3 -I 4 -l 100%FREE -n swap_1</code>pve-main-vg</p>
  </li>
  <li>
    <p>Делаем раздел для будущей подкачки собственно подкачкой: mkswap /dev/pve-main-vg/swap_1</p>
  </li>
  <li>
    <p>Копируем из /mnt/backups/root_copy в /dev/pve-main-vg/root все обратно тем же rsync, разберетесь как.</p>
  </li>
</ol>

<p>Без раздела подкачки не обойтись даже если памяти много. Раздел в скриптах PVE используется, так что сделал. В итоге у нас есть /dev/pve-main-vg/root, который работает со скоростью в гигабайты в секунду не напрягаясь. Диски подготовлены, пора переходить к настройке PVE. Сначала установим PVE ядро, что бы там KMS работал (работает ли он не проверял пока):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aptitude install proxmox-kernel-6.5.13-1-pve-signed 
</code></pre></div></div>

<p>Обратите внимание, я поставил подписанное ядро так как у меня Secure Boot в UEFI Bios разрешен. Потом я намудрил с MOK и пришлось его подписывать снова как описано тут (<a href="https://pve.proxmox.com/wiki/Secure_Boot_Setup">https://pve.proxmox.com/wiki/Secure_Boot_Setup</a>). Но думаю вы обойдетесь, на всякий случай ссылку даю. Далее можно настроить фаервол.</p>

<p>Фаервол устроен в PVE интеренсно. Есть настройки датацентра, настройки отдельных нод и настройки отдельных виртуалок. Что бы он заработал его надо на всех уровнях включить. Каждый следующий уровень наследует правила предыдущего. Важно сначала настроить правила, а потом включить фаервол, иначе он все зарежет. Правила на уровне датацентра такие как на картинке:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/02.png" alt="" /></p>

<p>Тут открыты все порты, что использует для различных нужд PVE и порты по которым доступны разные виды консолей управления. После создания правил надо на всех уровнях разрешить фаервол, покажу уровень датацентра:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/03.png" alt="" /></p>

<p>Фаервойл настроен и включен, пора настроить PBS, что бы иметь много бэкапов! Для этого заходим в консоль управления PBS: <a href="https://192.168.1.230:8007">https://${host-ip}:8007</a> жмем кнопку add datastore и добавляем наш том с двумя HDD: /dev/backups/backups_2_stripe как хранилище там ничего сложного нет, разберетесь. Один момент, добавляется папка на этом томе потому она у меня монтируется при загрузке сервера для чего в /etc/fstab написано:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/dev/mapper/backups-backups_striped_volume /mnt/backups               ext4    errors=remount-ro 0       1
</code></pre></div></div>

<p>С момента добавления хранилища нужно сходить в PVE и добавить PBS в качестве storage для бэкапов. Немного странная конструкция на мой вкус, но у них так. Да, установка PVE и PBS на один физический сервер не рекомендована, но так как если все это накроется то настроить снова это много текста в статье, но немного команд, а бэкапы останутся, то я не стал усложнять. Собственно добавление PBS в PVE:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/04.png" alt="" /></p>

<p>Fingerprint для того что бы добавить PBS как хранилище в PVE можно взять в PBS открыв сертификат, откуда и скопипастить, остальное разберетесь:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/05.png" alt="" /></p>

<p>Теперь мы можем нажать кнопку “бэкапь!”:</p>

<p><img src="/blog/assets/images/proxmox-personal-cloud/06.png" alt="" /></p>

<p>Так же мы можем забэкапить сам PVE сервер. На деле от него нам надо только папку /etc и особенно /etc/pve. Но /etc/pve это точка монтирования fuse FS, так что команда на бэкап должна особо сказать, что ее надо тоже забрать так как по умолчанию PBS клиентъ пропускает точки монтирования. Я для теста залил в бэкап вообще весь диск он точки монтирования пропустил кроме той, что я ему указал:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>proxmox-backup-client backup root.pxar:/ --repository ${host-ip}:proxmox-backup-server-data --include-dev /etc/pve
</code></pre></div></div>

<p>proxmox-backup-server-data это так называется HDD LVM том, добавленный в PBS в качестве хранилища. Люблю длинные самодокументирующие названия.</p>

<p>На пока все, эта конструкция позволит создавать виртуалки, бэкапить дедуплицированно-сжатые бэкапы и вообще куролесить. В следующий раз про сеть, если настроение будет.</p>

<p>UPD: Я особо не разбирался, но кажется что бы работал KSM надо установить пакет aptitude install ksmtuned. При этом освобождение памяти начнется при 80% ее использовании. ОЗУ у меня много для моих задач, так что пока не нужно. Оставлю для желающих разобраться <a href="https://pve.proxmox.com/wiki/Dynamic_Memory_Management">https://pve.proxmox.com/wiki/Dynamic_Memory_Management</a></p>

<p>P.S Для настроек и консультаций, а так же как учебник очень рекомендую использовать ChatGPT. Она отвечает на конкретный вопрос и не надо долго копаться, потом достаточно погуглить ответ что бы проверить, что не врет. Чаще всего не врет :) Реально очень удобно и она всегда вежлива, не приходится продираться через форумных выпендрежников ради простого ответа :)</p>]]></content><author><name></name></author><category term="proxmox ve" /><category term="proxmox backup server" /><category term="cloud" /><summary type="html"><![CDATA[В прошлой статье я писал как поставить Proxmox 8 на Orange Pi 5(b). Это даже получилось, но я уперся в то, что производитель железки не выпускает свежие ядра под нее. Для железки есть 5.10.160 и оно устарело для Proxmox 8. Виртуалки работают, но есть проблемы с фаерволом. Производитель обещает вот-вот выпустить новое, но ждать я не стал и решил сделать инфраструктуру по другому. О чем и напишу. Получается уже цикл статей. Эта будет описывать цель и первые шаги к ней.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://3draven.github.io/blog/assets/images/proxmox-personal-cloud/cover.png" /><media:content medium="image" url="https://3draven.github.io/blog/assets/images/proxmox-personal-cloud/cover.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">OrangePi 5 с Proxmox 8</title><link href="https://3draven.github.io/blog/2024/02/18/orangepi5-proxmox8.html" rel="alternate" type="text/html" title="OrangePi 5 с Proxmox 8" /><published>2024-02-18T12:21:21+00:00</published><updated>2024-02-18T12:21:21+00:00</updated><id>https://3draven.github.io/blog/2024/02/18/orangepi5-proxmox8</id><content type="html" xml:base="https://3draven.github.io/blog/2024/02/18/orangepi5-proxmox8.html"><![CDATA[<p>В <a href="/blog/2024/01/28/edk2-uefi-debian-proxmox-orangepi5.html">прошлой статье</a> я описал начальную настройку загрузчика для OrangePI 5b (для OrangePI 5 тоже сработает), а так же настройку и запуск Proxmox 7 до состояния “оно запустилось”. Виртуалки там я не пробовал и оказалось, что они и не работают без дополнительных телодвижений. В этой статье я опишу как настроить уже Proxmox 8.1.3 и довести до полностью работоспособного состояния.</p>

<hr />

<p>Предполагается, что из прошлой статьи у вас уже настроен EDK II загрузчик и вы можете свободно загружаться откуда угодно, а значит дело только в том что бы подготовить образ и настроить proxmox. Этим и займемся.</p>

<p>В качестве базового дистрибутива я выбрал Armbian образ с названием /Armbian_23.11.1_Orangepi5_bookworm_legacy_5.10.160.img.xz, тому есть несколько причин.</p>

<ul>
  <li>
    <p>Он пользуется репозиториями дебиана, а значит обновляется.</p>
  </li>
  <li>
    <p>Линуксовое ядро у них содержит модуль fuse, которого в ядре в составе дистрибутива от авторов OIrange PI5 нет, а он нужен для proxmox, либо я недомудрил с модулями. Без этого модуля proxmox не установится.</p>
  </li>
  <li>
    <p>Ядро в данном образе 5.10, для rk3588s еще нет версии ядра 6.1, производитель только делает.</p>
  </li>
</ul>

<p>Как ни странно proxmox поставится только при установке armbian на SSD диск. Пробовал на sdcard поставить, что то не пошло, не стал разбираться. Образ нужно подготовить для UEFI загрузки, что бы его EDK II легко увидел. В прошлой статье я писал, что SSD у меня подключен в USB 3 порт на Orange PI5b так что я просто переткнул его в ноутбук. Так как я пользуюсь linux, то просто в nautilus нажал правую кнопку на образе и выбрал “запись образа” после чего указал, что его надо записать на SSD. Есть много способов залить образ на SSD диск. После того как образ записан на SSD нужно смонтировать разделы оттуда</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mount /dev/sde1 /mnt/1
mount /dev/sde2 /mnt/2
</code></pre></div></div>

<p>С первого раздела надо все перенести на второй в папку /boot. Там ядро с вышеописанным модулем fuse и прочие нужные для загрузки файлы в том числе папка dtb в которой лежат файлы с описанием оборудования на вашей версии OrangePI 5(b) обратите внимание, что нужно будет указать в grub.cfg и armbianEnv.txt верный файл.</p>

<p>Далее берем образ где есть UEFI загрузчик, например я взял его с debian-bookworm-live-arm64.hybrid.iso как писал в прошлой статье. Копируем папки /EFI и /boot/grub с этого образа на первый раздел SSD диска, который там после записи туда armbian образа появился. После чего нужно отредактировать /boot/grub/grub.cfg сделав его таким.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>search --no-floppy --fs-uuid b5d4f702-2288-4851-be51-175a746d1b05 --set=root --hint hd0,gpt2
devicetree /boot/dtb/rockchip/rk3588s-orangepi-5b.dtb
linux /boot/Image root=UUID=b5d4f702-2288-4851-be51-175a746d1b05 ro rootwait rootfstype=ext4
initrd /boot/initrd.img-5.10.160-legacy-rk35xx
boot
</code></pre></div></div>

<p>Меню я делать не стал. Обратите внимание, что b5d4f702-2288-4851-be51-175a746d1b05 это UUID root файловой системы с конкретного образа armbian, если у вас другой, то UUID будет другой, узнать его можно с помощью команды blkid или gparted. Так же важно, что бы файл rk3588s-orangepi-5b.dtb был для вашей версии железа, в моем случае это именно 5b.dtb, в папке dtb много вариантов.</p>

<p>Загрузчик готов, можно переходить к подготовке armbian к работе с proxmox. Для этого отредактируем файлы armbianEnv.txt armbian_first_run.txt.template не уверен, что это обязательно, но я сделал. Содержимое файлов такое</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>armbianEnv.txt Обратите внимание, что UUID и dtb нужны тоже верные.

verbosity=0
bootlogo=false
overlay_prefix=rockchip-rk3588
fdtfile=rockchip/rk3588s-orangepi-5b.dtb
rootdev=UUID=b5d4f702-2288-4851-be51-175a746d1b05
rootfstype=ext4

armbian_first_run.txt.template

FR_general_delete_this_file_after_completion=1
FR_net_ethernet_enabled=1
FR_net_wifi_enabled=1
FR_net_wifi_ssid='имя вашего вайфая'
FR_net_wifi_key='пароль к вашему вайфаю'
FR_net_wifi_countrycode='GB'
FR_net_use_static=1
FR_net_static_ip='192.168.1.130'
FR_net_static_mask='255.255.255.0'
FR_net_static_gateway='192.168.1.1'
FR_net_static_dns='8.8.8.8 8.8.4.4'
</code></pre></div></div>

<p>Тут важно отметить, что адрес 192.168.1.130 мне выдал мой роутер с DHCP сервером и я на нем закрепил этот адрес сразу за MAC адресом данной Orange PI5b сетевой карты. Для proxmox важно что бы адреса были статические. Но мы в любом случае далее сделам все статическим.</p>

<p>В соответствии с инструкцией в статье (<a href="https://github.com/jiangcuo/Proxmox-Port/wiki/Install-Proxmox-VE-on-Debian-bookworm">https://github.com/jiangcuo/Proxmox-Port/wiki/Install-Proxmox-VE-on-Debian-bookworm</a>) редактируем файл /etc/hosts</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>127.0.0.1   localhost
192.168.1.130   orangepi5.proxmox.com orangepi5
::1         localhost orangepi5 ip6-localhost ip6-loopback
fe00::0     ip6-localnet
ff00::0     ip6-mcastprefix
ff02::1     ip6-allnodes
ff02::2     ip6-allrouters
</code></pre></div></div>

<p>В этом файле тот же адрес 192.168.1.130 имя хоста orangepi5 оно такое по умолчанию в Armbian. При этом обязательно надо удалить запись 127.0.1.1 иначе вероятно у proxmox будут проблемы. В целом редактирование этого файла обязательная процедура, без этого Proxmox не установится скорее всего.</p>

<p>Переходим к файлу /etc/apt/sources.list в него нужно добавить репозиторий</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>deb [arch=arm64] https://mirrors.apqa.cn/proxmox/debian/pve bookworm port
</code></pre></div></div>

<p>Собственно там лежит порт Proxmox 8.1.3. Так же нужно добавить в папку /etc/apt/trusted.gpg файл с ключом</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo curl https://mirrors.apqa.cn/proxmox/debian/pveport.gpg -o /etc/apt/trusted.gpg.d/pveport.gpg 
</code></pre></div></div>

<p>Переходим к файлу /etc/network/interfaces его содержимое</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>source /etc/network/interfaces.d/*
auto lo end1
iface lo inet loopback

iface end1 inet6 auto
iface end1 inet static
        address 192.168.1.130
        netmask 255.255.255.0
        gateway 192.168.1.1
</code></pre></div></div>

<p>Статический адрес все тот же, что мне выдал роутер 192.168.1.130. Все файлы готовы. Далее можно отмонтировать SSD и подключить его к OrangePI 5b. EDK II найдет его сам или можно выбрать в его меню диск и загрузится с него. Начальную настройку Armbian описывать не буду она совсем простая. После того как Armbian загружен нужно сначала отключить NetworkManager с ним у Proxmox проблемы, он сам рулит сетевыми интерфейсами.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo systemctl stop NetworkManager.service
sudo systemctl disable NetworkManager.service
</code></pre></div></div>

<p>После чего я перезагрузился для надежности. В силу вступят настройки сетевого интерфейса из /etc/network/interfaces. Далее обновляем репозитории и устанавливаем собственно proxmox</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo aptitude update
sudo aptitude install ifupdown2 proxmox-ve open-iscsi
</code></pre></div></div>

<p>aptitude в процессе разрешит конфликты в зависимостях и предложит удалить пакет vlan, я согласился. После чего Proxmox 8.1.3 был благополучно установлен на OrangePI 5b. Если будут проблемы с установкой, установочные скрипты proxmox не показывают ошибок, их нужно смотреть через sudo journalctl -b. Но это еще не все. Если не откатить пакет с прошивками то виртуалки работать не станут, просто будут грузить CPU на 100% и висеть.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>apt download pve-edk2-firmware-aarch64=3.20220526-rockchip
dpkg -i pve-edk2-firmware-aarch64_3.20220526-rockchip_all.deb
</code></pre></div></div>

<p>После того как это сделано можно перезагрузить девайс и с ноутбука зайти в консоль по адресу <a href="https://192.168.1.130:8006">https://192.168.1.130:8006</a> конкретный айпишник у вас будет свой конечно же. Логопас такой же для рута как вы задали при настройке Armbian. На этом настройка еще не завершена.</p>

<p>Нужно создать bridge, а так как теперь proxmox рулит в /etc/network/interfaces настройками сети, а за счет ifupdown2 девайс для смены настроек не надо перегружать, то в консоли надо сначала удалить IP и Gateway у интерфеса end1 (см. Datacenter-&gt;orangepi5-&gt;System-&gt;Network) не применяя настроек, это очень важно, иначе сеть отвалится, кнопку Apply Configuration нажимать нельзя! После чего нужно создать Linux Bridge задав ему тот же адрес что выдал вам роутер 192.168.1.130, гейт 192.168.1.1 (которые вы отняли у end1) и Bridge ports end1. Вот только потом можно нажать Apply Configuration и оба действия будут выполнены вместе на стороне OrangePI 5b. С этого момента у вас в консоли появится два сетевых интерфейса как на картинке. <img src="/blog/assets/images/orangepi5-proxmox8/01.png" alt="" /></p>

<p>Далее создадим первую виртуалку, ее тоже надо настроить строго определенным образом (<a href="https://github.com/jiangcuo/Proxmox-Arm64/wiki/Qemu-VM">https://github.com/jiangcuo/Proxmox-Arm64/wiki/Qemu-VM</a>). Сначала закачаем iso дистра, который хотим поставить. Разберетесь как в proxmox заливать данные в хранилище. В моем случае это ubuntu-22.04.3-live-server-arm64.iso. Потом создаем виртуальную машину указав, что в CD привод надо этот образ воткнуть. Обратите внимание, что когда грузить его будете надо подождать, он стартует долго. Любопытный факт, виртуалку грузить будет все тот же EDK II. Другие настройки виртуальной машины на картинке <img src="/blog/assets/images/orangepi5-proxmox8/02.png" alt="" /></p>

<p>Тут важно отметить, что если вы собираетесь использовать несколько ядер в виртуалке, то нужно указать к каким ядрам rk3588s привязать эту виртуалку как на картинке <img src="/blog/assets/images/orangepi5-proxmox8/03.png" alt="" />там видно, что я выбрал 2 ядра и указал, что виртуальные ядра надо привязать к физическим ядрам процессора из диапазона 0-1. Дело в том, что ядра в SoC разного типа, одни мощные, вторые нет и нельзя запустить виртуалку на одном мощном, а втором слабом так что обязательно надо указать affinity. Для одноядерных виртуалок это кажется не нужно. Так же обратите внимание на тип CPU host.</p>

<p>Все, можно запускать виртуалку и ставить ubuntu в ней. Теперь не придется по сто раз переставлять дистр, есть снапшоты и множество прочих удобств для того что бы не мусорить в одном дистре чем попало. Есть даже LXC контейнеры, это не просто докер, а контейнер системы в целом, легковеснее чем виртуалки. При этом все это на OrangePI 5b работающее почти от батареек, на паре таких девайсов на моей полке можно запустить восемь двухядерных VPS прямо дома бесплатно фактически! Оно еще и с пассивным охлаждением греется при нагрузке до 45 градусов всего!</p>

<p><img src="/blog/assets/images/orangepi5-proxmox8/04.png" alt="" /></p>

<p>UPD: После установки Ubuntu рекомендую установить в ней Qemu guest agent (<a href="https://pve.proxmox.com/pve-docs/pve-admin-guide.html#qm_qemu_agent">https://pve.proxmox.com/pve-docs/pve-admin-guide.html#qm_qemu_agent</a>) это значительно дополнит статистику о состоянии VM</p>

<p>UPD: Если используете в виртуалке графический рабочий стол так же удобно поставить spice-vdagent, он позволяет с веб консоли копипастить текст и файлы прямо в виртуалку. Описано там же в мануале на proxmox в разделе 10.2.8</p>

<p>UPD: В старом ядре 5.10 нет KSM, так что с нетерпением жду 6.1 или просто с KSM. Вроде бы производитель обещал еще в Q4 2023 выпустить 6.1, но пока не видать.</p>

<p>UPD: Так как все то же самое можно раскатать на RPi 4 то к своему кластеру в proxmox я и ее добавлю, а то валяется давно.</p>

<p>UPD: Обратите внимание, что по умолчанию proxmox firewall не работает и не показывает никаких ошибок (см. journalctl -b). Что бы заработал скорее всего надо включить в веб консоли в его опциях tcp flags filtering. Осторожно, надо сначала правилами разрешить порты, а то он всю внешку отрежет. Но останется проблема с созданием ipset’s. Если они не нужны то вроде как и ладно, если нужны, то надо патчить фаервол или обновлять ядро. Но распишу как все это решать в следующий раз. Может быть.</p>

<p>UPD: Так как ждать выпуска свежего ядра я не хочу, то использовал пока Intel NUC. О чем далее <a href="/blog/2024/03/02/proxmox-personal-cloud.html">Личное облако на Proxmox</a></p>

<p>P.S. Фактически это способ дешево получить в свое распоряжение настоящее взрослое облако с ceph, сложными сетями, бэкапами, снапшетами, мониторингом, репликацией и бесконечным полем для экспериментов ;)</p>]]></content><author><name></name></author><category term="orangepi" /><category term="proxmox ve" /><category term="linux-сервер" /><category term="proxmox" /><summary type="html"><![CDATA[В прошлой статье я описал начальную настройку загрузчика для OrangePI 5b (для OrangePI 5 тоже сработает), а так же настройку и запуск Proxmox 7 до состояния "оно запустилось". Виртуалки там я не пробовал и оказалось, что они и не работают без дополнительных телодвижений. В этой статье я опишу как настроить уже Proxmox 8.1.3 и довести до полностью работоспособного состояния.]]></summary></entry></feed>