Перейти к содержимому

Развёртывание Remote Hub

Remote Hub хранит учётные данные провайдеров, каталог и статистику на одном хосте. Авторизованные клиенты обращаются непосредственно к его плоскости данных. Контур управления отделён: необязательный listener привязан только к 127.0.0.1 и обслуживает панель и /api/*, но не /v1/*, /healthz, /readyz или WebSocket. Не публикуйте 10101 и не используйте Tailscale Funnel.

standalone объединяет всё на одной машине; hub владеет секретами и статистикой; client хранит только состояние подключения и отдельный ключ данных.

Terminal window
ocx connect https://hub-name.tailnet-name.ts.net --pairing-code-stdin
ocx connect status
ocx sync

Ключ клиента записывается в защищённый service-api-token, а не в config.json. При подключении статистика читается с hub и фильтруется по apiKeyId; после отключения используется локальное хранилище. Зеркалирования нет.

Admin token разрешает обычное управление, но никогда не создаёт consent session. Для действий с согласием нужны gui-session, совпадающий Origin и CSRF. Заголовок Tailscale-User-Login доверен только отдельному management ingress; точные логины задаются в remoteGui.allowedTailscaleUsers.

Terminal window
ocx config set runtimeRole hub
ocx config set hostname 100.64.0.10
ocx 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

Если конфигурация действительно пуста, каждый объект можно задать одним вызовом:

Terminal window
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 не содержат его значения.

Terminal window
curl --fail --silent http://100.64.0.10:10100/healthz
curl --fail --silent http://100.64.0.10:10100/readyz
tailscale serve --bg --https=443 http://127.0.0.1:10101
tailscale serve status

/healthz подтверждает только работу процесса. Проверьте также /readyz, авторизованный GET /v1/catalog и реальный ответ модели. Собственный TLS-прокси должен использовать tailscale cert hub-name.tailnet-name.ts.net и проксировать только на 127.0.0.1:10101. Не подделывайте Tailscale-User-*; без доверенной идентификации используйте одноразовое pairing.

Показанное выше сопоставление Serve публикует только управляющий вход. Он никогда не отдаёт /v1/*, /healthz и /readyz, поэтому сам по себе не даёт удалённому клиенту работоспособного плана данных. opencodex к тому же не терминирует TLS: слушатель работает по открытому HTTP, а HTTPS всегда обеспечивает фронтенд на стороне оператора.

Serve может стать таким фронтендом и для плана данных — на втором HTTPS-порту. В macOS нужен ещё один переход: Tailscale Serve проксирует только на 127.0.0.1 и не может указывать на слушателя, привязанного к собственному tailnet-адресу узла, а сборка macOS-клиента из App Store прямо отказывает удалённому назначению. Запустите на hub локальный форвардер и направьте Serve на него:

Terminal window
# Подойдёт любой 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:10110
tailscale serve status # ожидаются оба сопоставления: 443 -> 10101 и 8443 -> 10110

Serve принимает ограниченный набор HTTPS-портов; убедитесь через tailscale serve status, что сопоставление действительно создано, вместо того чтобы считать порт разрешённым. Дайте форвардеру тот же срок жизни, что и hub: фоновая задача оболочки умирает при перезагрузке, а служба возвращается, и остаётся работающий hub, недоступный по TLS. Запускайте форвардер из launchd или systemd рядом с ocx service install.

Затем подключайтесь, указывая оба origin по отдельности. Позиционный URL — это origin данных, именно оттуда берутся /readyz и /v1/catalog; --management-url — origin панели, который используется для pairing и выдачи ключа. Совпадение портов не требуется:

Terminal window
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 и один реальный маршрутизированный ответ.

Terminal window
ocx config set oauthOpenBrowser false
ocx connect rotate --pairing-code-stdin
# только HTTPS:
ocx connect rotate --admin-token-stdin

OAuth запускается через 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 доступен только пока клиент подключён.

При откате сохраняйте оба тома и их точки монтирования. Владельцы и права существующих томов не исправляются автоматически. Именованные тома вне 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.

Terminal window
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
bun scripts/generate-compatibility-version.ts
docker compose build
openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-token.ts
docker 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/catalog403 origin_rejected: слушатель данных привязан к loopback за TLS-фронтендом, см. «TLS для слушателя данных» выше.
  • Logout/expiry браузерной сессии не отзывает ключ данных.
  • Перед tailscale serve reset просмотрите все mappings через tailscale serve status.