Развёртывание Remote Hub
Remote Hub хранит учётные данные провайдеров, каталог и статистику на одном хосте. Авторизованные клиенты обращаются непосредственно к его плоскости данных. Контур управления отделён: необязательный listener привязан только к 127.0.0.1 и обслуживает панель и /api/*, но не /v1/*, /healthz, /readyz или WebSocket. Не публикуйте 10101 и не используйте Tailscale Funnel.
Роли и границы доверия
Заголовок раздела «Роли и границы доверия»standalone объединяет всё на одной машине; hub владеет секретами и статистикой; client хранит только состояние подключения и отдельный ключ данных.
ocx connect https://hub-name.tailnet-name.ts.net --pairing-code-stdinocx connect statusocx syncКлюч клиента записывается в защищённый service-api-token, а не в config.json. При подключении статистика читается с hub и фильтруется по apiKeyId; после отключения используется локальное хранилище. Зеркалирования нет.
Admin token разрешает обычное управление, но никогда не создаёт consent session. Для действий с согласием нужны gui-session, совпадающий Origin и CSRF. Заголовок Tailscale-User-Login доверен только отдельному management ingress; точные логины задаются в remoteGui.allowedTailscaleUsers.
Сервис и Tailscale Serve
Заголовок раздела «Сервис и Tailscale Serve»ocx config set runtimeRole hubocx config set hostname 100.64.0.10ocx config set corsAllowOrigins '["http://localhost:10100"]'
# В новой standalone-конфигурации нет объектов `hub` и `remoteGui`, а `ocx config set`# не создаёт отсутствующего родителя: вложенный путь завершится ошибкой# `config parent path not found: hub`. Установка `runtimeRole` его тоже не создаёт.# Сначала создайте каждый объект, затем задавайте его поля.ocx config set hub '{}'ocx config set remoteGui '{}'ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"'ocx config set hub.managementIngress '{"enabled":true,"port":10101}'ocx config set remoteGui.allowedTailscaleUsers '["operator@example.com"]'export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"ocx service installЕсли конфигурация действительно пуста, каждый объект можно задать одним вызовом:
ocx config set hub '{"managementPublicOrigin":"https://hub-name.tailnet-name.ts.net","managementIngress":{"enabled":true,"port":10101}}'ocx config set remoteGui '{"allowedTailscaleUsers":["operator@example.com"]}'Эта форма годится, только пока объекта нет. Присваивание объекта целиком заменяет его, а не сливает с прежним: выполнив строку выше над конфигурацией, где уже был hub.managementIngress, вы молча потеряете этот ingress. Когда вы правите существующую конфигурацию, родитель уже на месте — задавайте по одному полю вложенным путём, и остальное останется нетронутым.
Принятие строки решают две детали. Значение сначала разбирается как JSON и лишь затем трактуется как обычная строка — поэтому URL пишется как '"https://…"', а объекты, массивы, булевы значения и числа обязаны быть корректным JSON. Кроме того, hub и remoteGui строгие: и опечатка в ключе, и несоответствующее значение отклоняются прямо при записи ошибкой schema_invalid, а не превращаются в настройку, которая никогда не сработает. managementPublicOrigin должен быть чистым origin без пути, запроса и фрагмента.
systemd/launchd читает секрет из service-api-token; plist и unit не содержат его значения.
curl --fail --silent http://100.64.0.10:10100/healthzcurl --fail --silent http://100.64.0.10:10100/readyztailscale serve --bg --https=443 http://127.0.0.1:10101tailscale serve status/healthz подтверждает только работу процесса. Проверьте также /readyz, авторизованный GET /v1/catalog и реальный ответ модели. Собственный TLS-прокси должен использовать tailscale cert hub-name.tailnet-name.ts.net и проксировать только на 127.0.0.1:10101. Не подделывайте Tailscale-User-*; без доверенной идентификации используйте одноразовое pairing.
TLS для слушателя данных
Заголовок раздела «TLS для слушателя данных»Показанное выше сопоставление Serve публикует только управляющий вход. Он никогда не отдаёт /v1/*, /healthz и /readyz, поэтому сам по себе не даёт удалённому клиенту работоспособного плана данных. opencodex к тому же не терминирует TLS: слушатель работает по открытому HTTP, а HTTPS всегда обеспечивает фронтенд на стороне оператора.
Serve может стать таким фронтендом и для плана данных — на втором HTTPS-порту. В macOS нужен ещё один переход: Tailscale Serve проксирует только на 127.0.0.1 и не может указывать на слушателя, привязанного к собственному tailnet-адресу узла, а сборка macOS-клиента из App Store прямо отказывает удалённому назначению. Запустите на hub локальный форвардер и направьте Serve на него:
# Подойдёт любой loopback-форвардер TCP; socat — один из них. Выберите порт, который хаб# ещё не занял: при включённом loopback-companion 127.0.0.1:10100 принадлежит самому opencodex.socat TCP-LISTEN:10110,bind=127.0.0.1,fork,reuseaddr TCP:100.64.0.10:10100 &
tailscale serve --bg --https=8443 http://127.0.0.1:10110tailscale serve status # ожидаются оба сопоставления: 443 -> 10101 и 8443 -> 10110Serve принимает ограниченный набор HTTPS-портов; убедитесь через tailscale serve status, что сопоставление действительно создано, вместо того чтобы считать порт разрешённым. Дайте форвардеру тот же срок жизни, что и hub: фоновая задача оболочки умирает при перезагрузке, а служба возвращается, и остаётся работающий hub, недоступный по TLS. Запускайте форвардер из launchd или systemd рядом с ocx service install.
Затем подключайтесь, указывая оба origin по отдельности. Позиционный URL — это origin данных, именно оттуда берутся /readyz и /v1/catalog; --management-url — origin панели, который используется для pairing и выдачи ключа. Совпадение портов не требуется:
ocx connect https://hub-name.tailnet-name.ts.net:8443 \ --management-url https://hub-name.tailnet-name.ts.net \ --admin-token-stdinЕсли --management-url опущен, он берётся из ответа /readyz, который сообщает hub.managementPublicOrigin. Когда origin различаются, указать его явно понятнее.
Не сокращайте путь, привязывая слушатель данных к 127.0.0.1. Именно по loopback-привязке opencodex распознаёт сугубо локальное развёртывание: он перестаёт требовать ключ данных и начинает требовать, чтобы заголовок Host тоже был loopback. TLS-фронтенд передаёт Host: hub-name.tailnet-name.ts.net, поэтому /v1/catalog отвечает 403 origin_rejected, а /readyz, где этой проверки нет, по-прежнему возвращает 200. Развёртывание выглядит здоровым и не может отдать модель. Ничто в тракте запроса не читает X-Forwarded-Host, так что фронтенд это не исправит. Оставьте слушатель на tailnet-адресе: проверка ключа останется включённой, а проверка Host не применяется.
Привязка к 0.0.0.0 тоже работает и снимает нужду в форвардере, так как слушатель становится доступен и по loopback. Она публикует порт данных на всех интерфейсах, поэтому выбирайте её только там, где другие сети вас не волнуют.
Когда Serve поднят, повторите приёмочные проверки для HTTPS-origin данных: /readyz, авторизованный GET /v1/catalog и один реальный маршрутизированный ответ.
OAuth, ротация и отключение
Заголовок раздела «OAuth, ротация и отключение»ocx config set oauthOpenBrowser falseocx connect rotate --pairing-code-stdin# только HTTPS:ocx connect rotate --admin-token-stdinOAuth запускается через POST /api/oauth/login. Если callback недоступен, передайте итоговый URL или код как {provider,input} в POST /api/oauth/login/code. Не помещайте код в argv или логи.
При ротации старый и новый ключи действуют под одним apiKeyId не более десяти минут. Старый ключ сохраняется в service-api-token.prev, новый устанавливается атомарно и проверяется через /v1/catalog, затем подтверждается. При неопределённом результате повторите команду с временными полномочиями и не удаляйте кандидаты до проверки.
ocx disconnect восстанавливает локальное состояние даже без hub, но не отзывает удалённый ключ. После отключения отзыв возможен только на странице hub Integrations → API Keys. ocx connect revoke --admin-token-stdin доступен только пока клиент подключён.
Docker и устранение неполадок
Заголовок раздела «Docker и устранение неполадок»При откате сохраняйте оба тома и их точки монтирования. Владельцы и права существующих томов не исправляются автоматически. Именованные тома вне Compose и отдельные пути состояния описаны в основном руководстве.
Состояние хранится в двух отдельных томах: ocx-state для
OPENCODEX_HOME=/home/bun/.opencodex и codex-state для
CODEX_HOME=/home/bun/.codex. Форматы auth.json у двух продуктов несовместимы,
поэтому не объединяйте их домашние каталоги. Оба тома доступны для записи при
корневой файловой системе только для чтения.
Каталог моделей автоматически не создаётся. Перед проверкой авторизованного
/v1/catalog создайте или импортируйте корректный файл
/home/bun/.codex/opencodex-catalog.json. Для пустого каталога состояния ответ
404 catalog_not_found ожидаем. Обновление сохраняет ocx-state и добавляет
codex-state, но не переносит файлы автоматически. Если обходное решение хранило
каталог моделей в .opencodex, сначала сделайте резервную копию, затем перенесите
только каталог моделей с доступом лишь для владельца. Не перезаписывайте один
auth.json другим. При переопределении CODEX_HOME монтируйте именно эту директорию
для записи и сохраняйте каталог по умолчанию в ${CODEX_HOME}/opencodex-catalog.json.
Если model_catalog_json задаёт другой файл, его разрешённый путь также должен
храниться постоянно. До явного переноса сохраняйте прежнее соответствие переменных
окружения и томов. docker compose down сохраняет оба тома, а
docker compose down --volumes удаляет и ocx-state, и codex-state, включая
учётные данные, историю использования, ключ данных, состояние и каталог Codex.
Это разрушительная операция, а не способ обновления или перезапуска.
Официального Docker-образа нет, но репозиторий содержит поддерживаемые Dockerfile и compose.yaml для локальной сборки Bun-образа, закреплённого по digest. Перед первым запуском один раз передайте ключ данных через stdin; он не выводится и сохраняется с доступом только для владельца в volume ocx-state.
На хосте нужны Git и Bun. Перед каждой сборкой создавайте канонический манифест из отслеживаемых Git исходников и не меняйте их до завершения сборки. Сгенерированный JSON не добавляйте в Git; .git исключён из контекста Docker. По умолчанию порт хоста привязан к 127.0.0.1. Для удалённого доступа явно задайте OPENCODEX_BIND_ADDRESS=<LAN-или-Tailscale-IP> docker compose up -d; 0.0.0.0 открывает все интерфейсы. Защитите доступ брандмауэром и аутентифицированным TLS/tailnet-фронтендом.
Сборка отклоняет устаревший манифест, сверяя каждый SHA-256 с файлами контекста и затем образа. Отсутствующие или изменённые файлы, лишние исходники и символические ссылки запрещены. Обязательны package.json, bun.lock и единственный включаемый файл из scripts/ — scripts/model-metadata.source.json.
git clone https://github.com/lidge-jun/opencodex.gitcd opencodexbun scripts/generate-compatibility-version.tsdocker compose buildopenssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.tsdocker compose up -dКонтейнер работает от непривилегированного пользователя bun, с корневой файловой системой только для чтения и публикует только 10100. Не публикуйте 10101 и не помещайте секреты в ARG, ENV, COPY, Compose, историю образа или argv. После healthcheck отдельно проверьте readiness, аутентифицированный каталог и реальный запрос. docker compose down сохраняет volume; docker compose down --volumes удаляет также конфигурацию, учётные данные и ключ.
- При недоступном hub можно отключиться офлайн, но отзыв ключа останется незавершённым.
- LKG сохраняется только при временном сбое; при ошибке auth, схемы, размера или протокола локального fallback нет.
- Для
.prevсохраните оба файла и повторите ротацию с временными полномочиями. hub-too-new/hub-too-oldуказывает, какую сторону обновить; локальные записи ещё не сделаны.- Pairing одноразовый, попытки ограничены 429; потерянный код создайте заново.
- Pairing по не-loopback HTTP отклоняется сразу, и флага-исключения нет. Поставьте управляющий origin за HTTPS или выполняйте pairing по loopback; admin token по HTTP не отправляется.
/readyzотвечает200, а/v1/catalog—403 origin_rejected: слушатель данных привязан к loopback за TLS-фронтендом, см. «TLS для слушателя данных» выше.- Logout/expiry браузерной сессии не отзывает ключ данных.
- Перед
tailscale serve resetпросмотрите все mappings черезtailscale serve status.

