diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 15d5392c18..7c24706562 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -24,14 +24,32 @@ Le jeton admin permet la gestion ordinaire mais ne peut jamais créer une sessio ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# Une configuration standalone neuve n'a ni objet `hub` ni objet `remoteGui`, et +# `ocx config set` ne crée pas un parent manquant : un chemin imbriqué échoue avec +# `config parent path not found: hub`. Définir `runtimeRole` ne le crée pas non plus. +# Créez d'abord chaque objet, puis définissez ses champs. +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 ``` +Sur une configuration réellement vide, vous pouvez écrire chaque objet en un seul appel : + +```bash +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"]}' +``` + +N'utilisez cette forme que si l'objet n'existe pas encore. Affecter l'objet entier le **remplace** au lieu de fusionner : exécutée sur une configuration qui contenait déjà `hub.managementIngress`, la ligne ci-dessus supprime silencieusement cette entrée. Pour adapter une configuration existante, le parent est déjà là : définissez un champ à la fois, la forme imbriquée fonctionne et ne touche à rien d'autre. + +Deux détails décident qu'une ligne passe. La valeur est d'abord interprétée comme du JSON et retombe sur la chaîne brute, d'où l'écriture d'une URL en `'"https://…"'` : objets, tableaux, booléens et nombres doivent être du JSON valide. Ensuite, `hub` et `remoteGui` sont stricts : une clé mal orthographiée comme une valeur non conforme sont rejetées à l'écriture par une erreur `schema_invalid`, au lieu de devenir un réglage sans effet. `managementPublicOrigin` doit être une origine nue, sans chemin, requête ni fragment. + Le service lit le secret depuis `service-api-token`; le plist ou l’unité systemd ne contient pas sa valeur. ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` ne prouve que la vie du processus. Validez aussi `/readyz`, `GET /v1/catalog` authentifié et une vraie réponse routée. Le port de gestion doit écouter uniquement sur `127.0.0.1`. Pour un proxy TLS privé, utilisez `tailscale cert hub-name.tailnet-name.ts.net` et ne fabriquez jamais d’en-têtes `Tailscale-User-*`; utilisez l’association à usage unique. +### Donner du TLS à l'écoute de données + +Le mappage Serve ci-dessus ne publie que l'entrée de **gestion**. Celle-ci ne sert jamais `/v1/*`, `/healthz` ni `/readyz` : à elle seule, elle ne donne donc à un client distant aucun plan de données utilisable. opencodex ne termine par ailleurs aucun TLS : l'écoute est en HTTP clair et le HTTPS vient toujours d'un frontal détenu par l'opérateur. + +Serve peut aussi être ce frontal pour le plan de données, sur un second port HTTPS. Sur macOS, il faut un saut supplémentaire : Tailscale Serve ne relaie que vers `127.0.0.1` et ne peut donc pas viser l'écoute liée à l'adresse tailnet du nœud, tandis que la version App Store du client macOS refuse purement et simplement une destination distante. Lancez un relais local sur le hub et pointez Serve dessus : + +```bash +# N'importe quel relais TCP local convient; socat en est un. Choisissez un port que le hub +# n'utilise pas déjà : avec le companion de loopback activé, 127.0.0.1:10100 appartient à 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 # les deux mappages attendus : 443 -> 10101 et 8443 -> 10110 +``` + +Serve n'accepte qu'un jeu limité de ports HTTPS; confirmez avec `tailscale serve status` que le mappage a bien été créé plutôt que de supposer le port autorisé. Donnez au relais la même durée de vie qu'au hub : une tâche shell en arrière-plan meurt au redémarrage alors que le service revient, ce qui laisse un hub actif et injoignable en TLS. Lancez-le depuis launchd ou systemd, aux côtés de `ocx service install`. + +Connectez-vous ensuite en énonçant les deux origines séparément. L'URL positionnelle est l'origine **de données** — c'est là que sont récupérés `/readyz` et `/v1/catalog` — et `--management-url` est l'origine du tableau de bord, utilisée pour l'association et l'émission de clé. Elles ne partagent pas nécessairement le même port : + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +Quand `--management-url` est omis, il est repris de la réponse `/readyz`, qui rapporte `hub.managementPublicOrigin`. L'indiquer explicitement est plus clair lorsque les deux origines diffèrent. + +**Ne contournez pas cela en liant l'écoute de données à `127.0.0.1`.** Une liaison loopback est précisément ce à quoi opencodex reconnaît un déploiement purement local : il cesse d'exiger un identifiant de données et se met à exiger que l'en-tête `Host` de la requête soit lui aussi loopback. Un frontal TLS transmet `Host: hub-name.tailnet-name.ts.net`, donc `/v1/catalog` répond `403 origin_rejected` tandis que `/readyz`, qui n'applique pas ce contrôle, renvoie toujours `200`. Le déploiement paraît sain et ne peut servir aucun modèle. Rien dans le chemin de requête ne lit `X-Forwarded-Host`, le frontal ne peut donc pas corriger cela. Gardez l'écoute sur l'adresse tailnet : l'admission par identifiant y reste active et le contrôle `Host` ne s'applique pas. + +Lier `0.0.0.0` fonctionne aussi et supprime le besoin de relais, puisque l'écoute devient alors joignable en loopback. Cela publie le port de données sur toutes les interfaces : réservez-le aux hôtes dont les autres réseaux vous importent peu. + +Une fois Serve en place, rejouez les contrôles d'acceptation sur l'origine de données HTTPS : `/readyz`, `GET /v1/catalog` authentifié et une vraie réponse routée. + ## OAuth, rotation et déconnexion ```bash @@ -103,6 +154,7 @@ Le conteneur s’exécute avec l’utilisateur non-root `bun`, un système de fi - Récupération `.prev` : conservez les deux fichiers et relancez la rotation avec une autorité transitoire. - `hub-too-new`/`hub-too-old` : mettez à niveau le côté indiqué avant toute écriture locale. - Code d’association perdu ou épuisé : créez-en un nouveau; les essais sont limités avec 429. -- HTTP non local exige `--allow-insecure-http`; un jeton admin n’est jamais envoyé en HTTP. +- HTTP non local : l'association est refusée d'emblée et aucun indicateur ne permet d'y déroger. Placez l'origine de gestion derrière HTTPS, ou associez en loopback. Un jeton admin n’est jamais envoyé en HTTP. +- `403 origin_rejected` sur `/v1/catalog` alors que `/readyz` renvoie `200` : l'écoute de données est liée au loopback derrière un frontal TLS. Voir « Donner du TLS à l'écoute de données » ci-dessus. - Déconnexion/expiration de session navigateur n’affecte pas la clé de données. - Avant `tailscale serve reset`, inspectez `tailscale serve status`, car reset supprime tous les mappages. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index cffc233143..daa836ebec 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -24,14 +24,32 @@ ocx sync ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' 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 ``` +設定がまだ完全に空であれば、オブジェクトごと一度に書いても構いません。 + +```bash +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` がある設定に上の行を流すと、その値は黙って消えます。既存の設定を直すときは親が揃っているので、ネストしたパスで一つずつ設定すれば他には触れません。 + +行が通るかどうかを決めるのは二点です。値はまず JSON として解釈され、失敗すると生の文字列として扱われます。URL を `'"https://…"'` と書く理由がこれで、オブジェクト・配列・真偽値・数値は正しい JSON である必要があります。また `hub` と `remoteGui` は厳格なスキーマで、キーの打ち間違いも規格外の値も書き込み時点で `schema_invalid` エラーとして拒否されます。効かない設定が静かに残ることはありません。`managementPublicOrigin` はパス・クエリ・フラグメントを含まない裸の origin である必要があります。 + launchd/systemd は保護された `service-api-token` を読み、設定ファイルへ秘密値を埋め込みません。 ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` の `200` はプロセスの生存確認にすぎません。`/readyz`、認証済み `GET /v1/catalog`、実際のモデル応答も確認してください。独自 TLS プロキシでは `tailscale cert hub-name.tailnet-name.ts.net` を使い、`127.0.0.1:10101` のみに転送します。`Tailscale-User-*` を偽造せず、信頼できる ID がない場合は一度限りのペアリングを使います。 +### データリスナーに TLS を付ける + +上の Serve マッピングが公開するのは**管理**入口だけです。管理入口は `/v1/*`、`/healthz`、`/readyz` を提供しないため、それだけではリモートクライアントが使えるデータプレーンになりません。opencodex 自身は TLS を終端しません。リスナーは平文 HTTP で、HTTPS は常に運用者が用意するフロントエンドの役目です。 + +データプレーンも Serve で公開できます。HTTPS ポートをもう一つ使うだけです。macOS では一段だけ余分に必要になります。Tailscale Serve は `127.0.0.1` にしかプロキシできず、ノード自身の tailnet アドレスにバインドしたリスナーを指せません。App Store 版の macOS クライアントはリモート宛先自体を拒否します。hub 上にループバックのフォワーダーを立て、Serve をそちらへ向けてください。 + +```bash +# ループバック TCP フォワーダーなら何でも構いません。socat はその一つです。 +# ハブがまだ使っていないポートを選んでください。ループバック 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 では届かない状態が残ります。`ocx service install` と並べて launchd か systemd から起動してください。 + +接続時は二つの origin を別々に指定します。位置引数の URL が**データ** origin で、`/readyz` と `/v1/catalog` はここから取得されます。`--management-url` はペアリングとキー発行に使うダッシュボードの origin です。ポートが同じである必要はありません。 + +```bash +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` にバインドして近道しないでください。** ループバックへのバインドは、opencodex が純粋にローカルな配置だと判断する仕組みです。データ用の資格情報を要求しなくなる代わりに、リクエストの `Host` ヘッダーまでループバックであることを要求します。TLS フロントエンドは `Host: hub-name.tailnet-name.ts.net` をそのまま転送するので、`/v1/catalog` は `403 origin_rejected` を返し、その検査を行わない `/readyz` は `200` のままです。健全に見えるのにモデルを返せない配置ができあがります。リクエスト経路のどこも `X-Forwarded-Host` を読まないため、フロントエンド側では直せません。リスナーは tailnet アドレスに置いてください。資格情報の検査は有効なままで、`Host` 検査は適用されません。 + +`0.0.0.0` へのバインドでも動き、ループバックからも届くのでフォワーダーは不要になります。ただしデータポートが全インターフェースに公開されるため、他のネットワークを気にしなくてよいホストに限ってください。 + +Serve が立ち上がったら、HTTPS のデータ origin に対して `/readyz`、認証済み `GET /v1/catalog`、実際のモデル応答を改めて確認します。 + ## OAuth、キー更新、切断 ```bash @@ -104,6 +155,7 @@ docker compose up -d - `.prev` 復旧では二つのファイルを保持して一時権限付きで再実行します。 - `hub-too-new`/`hub-too-old` が示す古い側を更新してください。書き込み前に拒否されます。 - ペアリングコードは一度限りで、失敗は 429 制限されます。失った場合は再発行します。 -- 非ループバック HTTP は `--allow-insecure-http` が必要で、管理トークンは HTTP 送信されません。 +- 非ループバック HTTP のペアリングは拒否され、それを外すフラグはありません。管理 origin を HTTPS の背後に置くか、ループバックでペアリングしてください。管理トークンは HTTP 送信されません。 +- `/readyz` が `200` なのに `/v1/catalog` が `403 origin_rejected` を返す場合、データリスナーが TLS フロントエンドの背後でループバックにバインドされています。上の「データリスナーに TLS を付ける」を参照してください。 - ブラウザーのログアウト/期限切れはデータキーを失効させません。 - `tailscale serve reset` の前に全マッピングを確認してください。 diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index 0887baf9a8..964fe4e423 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -24,14 +24,32 @@ Admin token разрешает обычное управление, но ник ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' 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 ``` +Если конфигурация действительно пуста, каждый объект можно задать одним вызовом: + +```bash +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 не содержат его значения. ```bash @@ -43,6 +61,39 @@ 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. +### TLS для слушателя данных + +Показанное выше сопоставление Serve публикует только **управляющий** вход. Он никогда не отдаёт `/v1/*`, `/healthz` и `/readyz`, поэтому сам по себе не даёт удалённому клиенту работоспособного плана данных. opencodex к тому же не терминирует TLS: слушатель работает по открытому HTTP, а HTTPS всегда обеспечивает фронтенд на стороне оператора. + +Serve может стать таким фронтендом и для плана данных — на втором HTTPS-порту. В macOS нужен ещё один переход: Tailscale Serve проксирует только на `127.0.0.1` и не может указывать на слушателя, привязанного к собственному tailnet-адресу узла, а сборка macOS-клиента из App Store прямо отказывает удалённому назначению. Запустите на hub локальный форвардер и направьте Serve на него: + +```bash +# Подойдёт любой 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 и выдачи ключа. Совпадение портов не требуется: + +```bash +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, ротация и отключение ```bash @@ -106,6 +157,7 @@ docker compose up -d - Для `.prev` сохраните оба файла и повторите ротацию с временными полномочиями. - `hub-too-new`/`hub-too-old` указывает, какую сторону обновить; локальные записи ещё не сделаны. - Pairing одноразовый, попытки ограничены 429; потерянный код создайте заново. -- Для не-loopback HTTP нужен `--allow-insecure-http`; admin token по HTTP не отправляется. +- 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`. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index 6499325908..ae89276984 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -24,14 +24,32 @@ Admin token sıradan yönetim yapabilir ancak hiçbir zaman onay oturumu oluştu ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' ocx config set corsAllowOrigins '["http://localhost:10100"]' + +# Yeni bir standalone yapılandırmada `hub` veya `remoteGui` nesnesi yoktur ve +# `ocx config set` eksik bir üst nesneyi oluşturmaz: iç içe bir yol +# `config parent path not found: hub` hatasıyla başarısız olur. `runtimeRole` +# ayarlamak da onu oluşturmaz. Önce her nesneyi oluşturun, sonra alanlarını ayarlayın. +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 ``` +Yapılandırma gerçekten boşsa her nesneyi tek çağrıda da yazabilirsiniz: + +```bash +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"]}' +``` + +Bu biçimi yalnızca nesne henüz yokken kullanın. Nesnenin tamamını atamak birleştirmez, **değiştirir**: zaten `hub.managementIngress` içeren bir yapılandırmada yukarıdaki satırı çalıştırmak o girişi sessizce düşürür. Mevcut bir yapılandırmayı uyarlarken üst nesne zaten yerindedir; tek tek alan ayarlayın, iç içe biçim çalışır ve başka hiçbir şeye dokunmaz. + +Bir satırın kabul edilip edilmeyeceğini iki ayrıntı belirler. Değer önce JSON olarak ayrıştırılır, olmazsa ham dizgeye düşer; bir URL'nin `'"https://…"'` biçiminde yazılmasının nedeni budur ve nesneler, diziler, mantıksal değerler ve sayılar geçerli JSON olmak zorundadır. Ayrıca `hub` ve `remoteGui` katıdır: yanlış yazılmış bir anahtar da kurala uymayan bir değer de yazma anında `schema_invalid` hatasıyla reddedilir; hiçbiri hiçbir zaman etkili olmayan bir ayara dönüşmez. `managementPublicOrigin` yol, sorgu veya parça içermeyen çıplak bir origin olmalıdır. + systemd/launchd korumalı `service-api-token` dosyasını okur; plist veya unit içine gerçek sır yazılmaz. ```bash @@ -43,6 +61,39 @@ tailscale serve status `/healthz` yalnızca işlemin yaşadığını gösterir. `/readyz`, kimlik doğrulamalı `GET /v1/catalog` ve gerçek bir model yanıtını da doğrulayın. Kendi TLS proxy'niz için `tailscale cert hub-name.tailnet-name.ts.net` kullanın ve yalnızca `127.0.0.1:10101` hedefine yönlendirin. `Tailscale-User-*` başlıkları uydurmayın; güvenilir kimlik yoksa tek kullanımlık eşleştirme kullanın. +### Veri dinleyicisine TLS vermek + +Yukarıdaki Serve eşlemesi yalnızca **yönetim** girişini yayımlar. Bu giriş `/v1/*`, `/healthz` veya `/readyz` sunmaz; dolayısıyla tek başına uzak bir istemciye kullanılabilir bir veri düzlemi vermez. opencodex kendi TLS'ini de sonlandırmaz: dinleyici düz HTTP'dir ve HTTPS her zaman operatörün kendi ön ucudur. + +Serve, ikinci bir HTTPS portunda veri düzlemi için de bu ön uç olabilir. macOS'ta bir adım daha gerekir: Tailscale Serve yalnızca `127.0.0.1` adresine proxy yapar, yani düğümün kendi tailnet adresine bağladığınız dinleyiciyi hedefleyemez; macOS istemcisinin App Store sürümü ise uzak bir hedefi doğrudan reddeder. Hub üzerinde bir loopback yönlendirici çalıştırın ve Serve'ü ona yöneltin: + +```bash +# Herhangi bir loopback TCP yönlendirici iş görür; socat bunlardan biridir. Hub'ın halihazırda +# kullanmadığı bir port seçin: loopback companion açıkken 127.0.0.1:10100 opencodex'in kendisine aittir. +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 # iki eşleme de beklenir: 443 -> 10101 ve 8443 -> 10110 +``` + +Serve sınırlı bir HTTPS portu kümesini kabul eder; portun izinli olduğunu varsaymak yerine `tailscale serve status` ile eşlemenin gerçekten oluştuğunu doğrulayın. Yönlendiriciye hub ile aynı ömrü verin: arka plandaki bir kabuk işi yeniden başlatmada ölürken servis geri gelir ve ortada çalışan ama TLS üzerinden erişilemeyen bir hub kalır. `ocx service install` ile birlikte launchd veya systemd üzerinden çalıştırın. + +Ardından iki origin'i ayrı ayrı belirterek bağlanın. Konumsal URL **veri** origin'idir; `/readyz` ve `/v1/catalog` oradan alınır. `--management-url` ise eşleştirme ve anahtar verme için kullanılan pano origin'idir. Aynı portu paylaşmaları gerekmez: + +```bash +ocx connect https://hub-name.tailnet-name.ts.net:8443 \ + --management-url https://hub-name.tailnet-name.ts.net \ + --admin-token-stdin +``` + +`--management-url` atlandığında, `hub.managementPublicOrigin` değerini bildiren `/readyz` yanıtından alınır. İki origin farklıysa açıkça yazmak daha nettir. + +**Veri dinleyicisini `127.0.0.1` adresine bağlayarak kestirmeden gitmeyin.** Loopback bağlaması, opencodex'in dağıtımı tümüyle yerel saymasının yoludur: veri kimlik bilgisi istemeyi bırakır ve bunun yerine isteğin `Host` başlığının da loopback olmasını şart koşar. Bir TLS ön ucu `Host: hub-name.tailnet-name.ts.net` başlığını olduğu gibi iletir, bu yüzden `/v1/catalog` `403 origin_rejected` yanıtı verirken bu denetimi yapmayan `/readyz` hâlâ `200` döndürür. Dağıtım sağlıklı görünür ama model sunamaz. İstek yolundaki hiçbir kod `X-Forwarded-Host` okumaz, dolayısıyla ön uç bunu onaramaz. Dinleyiciyi tailnet adresinde tutun: kimlik bilgisi kabulü açık kalır ve `Host` denetimi uygulanmaz. + +`0.0.0.0` bağlaması da çalışır ve dinleyici loopback üzerinden de erişilebilir olduğundan yönlendirici ihtiyacını ortadan kaldırır. Veri portunu tüm arayüzlerde yayımladığı için yalnızca başka ağını önemsemediğiniz makinelerde tercih edin. + +Serve ayağa kalktıktan sonra kabul denetimlerini HTTPS veri origin'ine karşı yineleyin: `/readyz`, kimlik doğrulamalı `GET /v1/catalog` ve bir gerçek yönlendirilmiş yanıt. + ## OAuth, döndürme ve bağlantı kesme ```bash @@ -106,6 +157,7 @@ Konteyner root olmayan `bun` kullanıcısıyla, salt okunur kök dosya sistemiyl - `.prev` kurtarmasında iki dosyayı koruyup geçici yetkiyle yeniden çalıştırın. - `hub-too-new`/`hub-too-old` eski tarafı gösterir; yerel yazımdan önce reddedilir. - Eşleştirme tek kullanımlıktır ve hatalar 429 ile sınırlanır; kayıp kodu yeniden üretin. -- Loopback dışı HTTP için `--allow-insecure-http` gerekir; admin token HTTP ile gönderilmez. +- Loopback dışı HTTP eşleştirmesi doğrudan reddedilir ve bunu devre dışı bırakan bir bayrak yoktur. Yönetim origin'ini HTTPS arkasına alın ya da loopback üzerinden eşleştirin; admin token HTTP ile gönderilmez. +- `/readyz` `200` dönerken `/v1/catalog` `403 origin_rejected` veriyorsa, veri dinleyicisi bir TLS ön ucunun arkasında loopback'e bağlıdır. Yukarıdaki "Veri dinleyicisine TLS vermek" bölümüne bakın. - Tarayıcı logout/expiry veri anahtarını iptal etmez. - `tailscale serve reset` tüm eşlemeleri kaldırır; önce durumu inceleyin. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 6caa44fc23..47516f1dbd 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -24,14 +24,31 @@ Admin token 只能执行普通管理,永远不能创建用户同意会话。 ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' 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 ``` +如果配置确实还是空的,也可以一次性写入整个对象: + +```bash +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` 的配置执行上面这行,该入口会被悄悄丢掉。调整既有配置时父对象已经存在,用嵌套路径逐个字段设置即可,不会动到其他值。 + +有两点决定一行命令能否被接受。值先按 JSON 解析,失败才回退为原始字符串——这就是 URL 要写成 `'"https://…"'` 的原因,对象、数组、布尔值和数字都必须是合法 JSON。另外 `hub` 和 `remoteGui` 采用严格模式:键名写错或取值不合规,都会在写入时以 `schema_invalid` 错误被拒绝,而不会变成一个永远不生效的设置。`managementPublicOrigin` 必须是不带路径、查询和片段的纯 origin。 + systemd/launchd 从受保护的 `service-api-token` 读取密钥,plist 和 unit 不包含明文密钥。 ```bash @@ -43,6 +60,39 @@ tailscale serve status `/healthz` 只证明进程存活。还必须验证 `/readyz`、经过身份验证的 `GET /v1/catalog` 和一次真实模型响应。管理端口只能监听 `127.0.0.1`。自建 TLS 代理应使用 `tailscale cert hub-name.tailnet-name.ts.net`,并仅代理到 `127.0.0.1:10101`。不要伪造 `Tailscale-User-*`;没有可信身份时请使用一次性配对。 +### 为数据监听器提供 TLS + +上面的 Serve 映射只发布**管理**入口。该入口从不提供 `/v1/*`、`/healthz` 或 `/readyz`,因此仅凭它并不能让远程客户端获得可用的数据平面。opencodex 自身也不终结 TLS:监听器是明文 HTTP,HTTPS 始终由运维方自建的前端负责。 + +数据平面同样可以交给 Serve,只需再用一个 HTTPS 端口。在 macOS 上还要多一跳,因为 Tailscale Serve 只能代理到 `127.0.0.1`,无法指向你绑定在节点自身 tailnet 地址上的监听器,而 App Store 版 macOS 客户端会直接拒绝远程目标。请在 hub 上运行一个回环转发器,再让 Serve 指向它: + +```bash +# 任何回环 TCP 转发器都可以,socat 只是其中之一。请选一个 hub 尚未占用的端口: +# 启用回环 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 拥有相同的生命周期:后台 shell 作业会在重启时消失而服务会自行恢复,于是 hub 在运行却无法经 TLS 访问。请随 `ocx service install` 一起,用 launchd 或 systemd 托管它。 + +连接时把两个 origin 分开写。位置参数 URL 是**数据** origin,`/readyz` 和 `/v1/catalog` 都从这里获取;`--management-url` 是用于配对和密钥签发的控制台 origin。两者不必共用端口: + +```bash +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`。** 回环绑定正是 opencodex 判定“纯本地部署”的依据:它会不再要求数据凭据,转而要求请求的 `Host` 头也是回环地址。TLS 前端会原样转发 `Host: hub-name.tailnet-name.ts.net`,于是 `/v1/catalog` 返回 `403 origin_rejected`,而不做这项检查的 `/readyz` 仍然返回 `200`。部署看起来健康,却无法提供模型。请求路径中没有任何代码读取 `X-Forwarded-Host`,所以前端也无法修正。请把监听器留在 tailnet 地址上:凭据准入保持开启,而 `Host` 检查不会生效。 + +绑定 `0.0.0.0` 同样可行,而且因为回环也能访问,就不再需要转发器。但它会把数据端口发布到所有接口,所以只在你不在意其他网络的主机上这么做。 + +Serve 就绪后,请针对 HTTPS 数据 origin 重新执行验收检查:`/readyz`、经过身份验证的 `GET /v1/catalog` 和一次真实模型响应。 + ## OAuth、密钥轮换与断开 ```bash @@ -101,6 +151,7 @@ docker compose up -d - `.prev` 恢复:保留两个文件,使用临时权限重新运行轮换。 - `hub-too-new`/`hub-too-old` 会指出需要升级的一端,并在本地写入前失败。 - 配对码一次性使用,失败次数会触发 429;丢失后请重新创建。 -- 非回环 HTTP 配对必须显式使用 `--allow-insecure-http`;Admin token 绝不通过 HTTP 发送。 +- 非回环 HTTP 配对会被直接拒绝,且没有任何开关可以豁免。请把管理 origin 放到 HTTPS 之后,或改在回环上配对;Admin token 绝不通过 HTTP 发送。 +- `/readyz` 返回 `200` 而 `/v1/catalog` 返回 `403 origin_rejected`:说明数据监听器绑在回环地址上却位于 TLS 前端之后,参见上文“为数据监听器提供 TLS”。 - 浏览器 logout/expiry 只影响会话,不会吊销数据密钥。 - `tailscale serve reset` 会删除节点上的所有映射,请先查看 `tailscale serve status`。 diff --git a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md index 073bb6a6f5..cade45c84c 100644 --- a/docs-site/src/content/docs/zh-tw/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-tw/guides/remote-hub.md @@ -24,14 +24,31 @@ Admin token 只能執行一般管理,永遠不能建立使用者同意工作 ```bash ocx config set runtimeRole hub ocx config set hostname 100.64.0.10 -ocx config set hub.managementPublicOrigin '"https://hub-name.tailnet-name.ts.net"' 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 ``` +若設定確實還是空的,也可以一次寫入整個物件: + +```bash +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` 的設定執行上面那行,該入口會被悄悄丟掉。調整既有設定時父物件已經存在,用巢狀路徑逐一設定欄位即可,不會動到其他值。 + +有兩點決定一行命令能否被接受。值會先以 JSON 解析,失敗才退回原始字串——這就是 URL 要寫成 `'"https://…"'` 的原因,物件、陣列、布林值與數字都必須是合法 JSON。另外 `hub` 與 `remoteGui` 採用嚴格結構:鍵名打錯或取值不合規,都會在寫入當下以 `schema_invalid` 錯誤遭拒,而不會變成永遠不生效的設定。`managementPublicOrigin` 必須是不含路徑、查詢與片段的純 origin。 + systemd/launchd 從受保護的 `service-api-token` 讀取金鑰,plist 與 unit 不包含明文金鑰。 ```bash @@ -43,6 +60,39 @@ tailscale serve status `/healthz` 只證明程序仍在執行。還必須驗證 `/readyz`、已驗證的 `GET /v1/catalog` 與一次真實模型回應。管理連接埠只能監聽 `127.0.0.1`。自管 TLS proxy 應使用 `tailscale cert hub-name.tailnet-name.ts.net`,並只代理到 `127.0.0.1:10101`。不要偽造 `Tailscale-User-*`;沒有可信身分時請使用一次性配對。 +### 為資料監聽器提供 TLS + +上面的 Serve 對應只發布**管理**入口。該入口從不提供 `/v1/*`、`/healthz` 或 `/readyz`,因此光靠它並不能讓遠端用戶端取得可用的資料平面。opencodex 本身也不終結 TLS:監聽器是明文 HTTP,HTTPS 一律由維運方自建的前端負責。 + +資料平面同樣可以交給 Serve,只要再用一個 HTTPS 連接埠。在 macOS 上還要多一跳,因為 Tailscale Serve 只能代理到 `127.0.0.1`,無法指向你綁在節點自身 tailnet 位址上的監聽器,而 App Store 版 macOS 用戶端會直接拒絕遠端目的地。請在 hub 上執行一個迴路轉送器,再讓 Serve 指向它: + +```bash +# 任何迴路 TCP 轉送器都可以,socat 只是其中之一。請選一個 hub 尚未佔用的連接埠: +# 啟用迴路 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 有相同的生命週期:背景 shell 工作會在重開機時消失而服務會自行復原,於是 hub 在執行卻無法經 TLS 連到。請隨 `ocx service install` 一起,用 launchd 或 systemd 託管它。 + +連線時把兩個 origin 分開寫。位置參數 URL 是**資料** origin,`/readyz` 與 `/v1/catalog` 都從這裡取得;`--management-url` 則是用於配對與金鑰簽發的儀表板 origin。兩者不必共用連接埠: + +```bash +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`。** 迴路繫結正是 opencodex 判定「純本機部署」的依據:它會不再要求資料憑證,改為要求請求的 `Host` 標頭也是迴路位址。TLS 前端會原樣轉送 `Host: hub-name.tailnet-name.ts.net`,於是 `/v1/catalog` 回應 `403 origin_rejected`,而不做這項檢查的 `/readyz` 仍然回應 `200`。部署看起來健康,卻無法提供模型。請求路徑中沒有任何程式碼會讀取 `X-Forwarded-Host`,所以前端也修不了。請把監聽器留在 tailnet 位址上:憑證准入維持開啟,而 `Host` 檢查不會生效。 + +繫結 `0.0.0.0` 同樣可行,而且因為迴路也連得到,就不再需要轉送器。但它會把資料連接埠發布到所有介面,所以只在你不在意其他網路的主機上這麼做。 + +Serve 就緒後,請對 HTTPS 資料 origin 重新執行驗收檢查:`/readyz`、已驗證的 `GET /v1/catalog` 與一次真實模型回應。 + ## OAuth、金鑰輪替與中斷連線 ```bash @@ -82,6 +132,7 @@ docker compose up -d - `.prev` 復原:保留兩個檔案,使用暫時權限重新執行輪替。 - `hub-too-new`/`hub-too-old` 會指出需要升級的一端,並在本機寫入前失敗。 - 配對碼只能使用一次,失敗次數會觸發 429;遺失後請重新建立。 -- 非迴路 HTTP 配對必須明確使用 `--allow-insecure-http`;Admin token 絕不透過 HTTP 傳送。 +- 非迴路 HTTP 配對會被直接拒絕,而且沒有任何開關可以豁免。請把管理 origin 放到 HTTPS 之後,或改在迴路上配對;Admin token 絕不透過 HTTP 傳送。 +- `/readyz` 回應 `200` 但 `/v1/catalog` 回應 `403 origin_rejected`:表示資料監聽器綁在迴路位址卻位於 TLS 前端之後,請參見上文「為資料監聽器提供 TLS」。 - 瀏覽器 logout/expiry 只影響工作階段,不會撤銷資料金鑰。 - `tailscale serve reset` 會刪除節點上的所有映射,請先查看 `tailscale serve status`。 diff --git a/tests/ci-workflows/docs-remote-hub-claims.test.ts b/tests/ci-workflows/docs-remote-hub-claims.test.ts index 23ef5bd0c8..e6812f31fb 100644 --- a/tests/ci-workflows/docs-remote-hub-claims.test.ts +++ b/tests/ci-workflows/docs-remote-hub-claims.test.ts @@ -19,12 +19,23 @@ * `export OPENCODEX_API_AUTH_TOKEN=…` step is the one that has to stay gone: it is how the * maintainer's hub ended up with a management admin token in the data-plane variable, and the * service now provisions its own token, so re-adding the line would re-teach the incident. + * + * Round one fixed the English source only, and the seven translated copies kept telling their + * readers to run the line that fails (#4200). That drift was unenforced because this oracle read + * one file. The locale-wide block below is the part that keeps the next English edit from + * silently leaving the translations behind; the markers it pins are commands and literal error + * codes, which survive translation, rather than prose a translator is supposed to rewrite. */ import { describe, expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; const GUIDE = repoPath("docs-site/src/content/docs/guides/remote-hub.md"); const KO_GUIDE = repoPath("docs-site/src/content/docs/ko/guides/remote-hub.md"); +const TRANSLATED = ["ko", "ja", "zh-cn", "zh-tw", "fr", "ru", "tr"] as const; +const LOCALE_GUIDES: ReadonlyArray = [ + ["en", GUIDE], + ...TRANSLATED.map(locale => [locale, repoPath(`docs-site/src/content/docs/${locale}/guides/remote-hub.md`)] as const), +]; describe("remote hub guide", () => { test("no nested config set runs before its parent object exists", async () => { @@ -160,3 +171,94 @@ describe("the one-port hub recipe", () => { expect(source).toContain("Do not point Serve at the loopback companion listener"); }); }); + +describe("remote hub guide translations", () => { + // Every locale is checked against the SAME expectations as the source, including "en" itself. + // Putting English in the list is deliberate: it means a future English edit that drops one of + // these markers fails here too, instead of quietly redefining what the locales owe. + for (const [locale, path] of LOCALE_GUIDES) { + describe(locale, () => { + test("no nested config set runs before its parent object exists", async () => { + const source = await Bun.file(path).text(); + + // Ordering is the whole fix. A guide that sets the field first and shows `{}` further + // down still fails verbatim on the fresh standalone config it told the reader to build. + for (const parent of ["hub", "remoteGui"] as const) { + const initializer = source.indexOf(`ocx config set ${parent} '{}'`); + const nested = source.indexOf(`ocx config set ${parent}.`); + expect(initializer, `${locale} no longer initializes an empty ${parent} object`).toBeGreaterThanOrEqual(0); + expect(nested, `${locale} no longer sets any ${parent} field`).toBeGreaterThanOrEqual(0); + expect( + initializer, + `${locale} sets a ${parent}. before creating ${parent}, which fails on a fresh config`, + ).toBeLessThan(nested); + } + + // The error text is terminal output, so it stays literal in every language: it is how a + // reader who already hit the failure recognizes their own screen. + expect(source, `${locale} no longer names the error a reader actually sees`) + .toContain("config parent path not found: hub"); + }); + + test("the whole-object alternative carries its replace-not-merge warning", async () => { + // `setPath` assigns the leaf, so the one-call form drops a pre-existing managementIngress. + // Each locale words the warning natively, so this pins shape: the alternative exists, and + // an emphasized caveat follows it before the section ends. Without the second half a + // locale could keep the convenient line and lose the reason it is dangerous. + const source = await Bun.file(path).text(); + const wholeObject = source.indexOf(`ocx config set hub '{"managementPublicOrigin"`); + expect(wholeObject, `${locale} lost the whole-object alternative`).toBeGreaterThanOrEqual(0); + + // Stop at the next heading of ANY level, not just `##`. Bounding on `##` alone let the + // bold text inside the following `###` data-plane subsection satisfy this check, so + // deleting the warning itself still passed -- the assertion was decorative in five of the + // eight files. Two or more hashes also keeps a `# comment` line inside a bash fence from + // closing the window early. + const nextHeading = source.slice(wholeObject).search(/\n#{2,6} /); + const section = source.slice(wholeObject, nextHeading < 0 ? undefined : wholeObject + nextHeading); + expect(section, `${locale} offers the whole-object form with no emphasized warning`).toContain("**"); + + // Emphasis alone is content-free -- any unrelated bold in the window would satisfy it. + // The warning's actual subject is the setting that silently disappears, and its name is + // a config path, so it survives translation. A locale that keeps the convenient one-call + // line and drops the reason it is dangerous fails here. + expect( + section, + `${locale} does not name hub.managementIngress as what a whole-object set drops`, + ).toContain("hub.managementIngress"); + }); + + test("the data plane is given TLS on its own origin", async () => { + // The management ingress serves no /v1/*, /healthz or /readyz, so a guide that publishes + // only that ingress leaves a hub that pairs and then cannot answer a request. These are + // commands, so a translation that dropped the section fails rather than reading fine. + const source = await Bun.file(path).text(); + expect(source, `${locale} lost the loopback forwarder macOS Serve requires`) + .toContain("socat TCP-LISTEN:10110,bind=127.0.0.1"); + expect(source, `${locale} lost the second HTTPS mapping for the data listener`) + .toContain("tailscale serve --bg --https=8443 http://127.0.0.1:10110"); + expect(source, `${locale} lost the data origin on ocx connect`) + .toContain("ocx connect https://hub-name.tailnet-name.ts.net:8443"); + expect(source, `${locale} lost the separate management origin`) + .toContain("--management-url https://hub-name.tailnet-name.ts.net"); + }); + + test("the quiet loopback-bind trap is documented", async () => { + // This is the failure the section exists for: a loopback-bound data listener behind a TLS + // frontend answers 403 on /v1/catalog while /readyz still returns 200, so the deployment + // looks healthy and serves no model. Both tokens are literal wire values in every locale. + const source = await Bun.file(path).text(); + expect(source, `${locale} lost the error code the operator actually sees`).toContain("403 origin_rejected"); + expect(source, `${locale} no longer says the frontend cannot repair this`).toContain("X-Forwarded-Host"); + }); + + test("the retired --allow-insecure-http flag is not offered", async () => { + // `rejectArgs` throws "Unexpected argument(s)" on it, pairing refuses non-loopback HTTP + // with no opt-out, and remoteGui.allowInsecureHttp is a retired no-op kept only so old + // configs still load. Offering it in any language sends that reader to an error. + const source = await Bun.file(path).text(); + expect(source, `${locale} still offers the retired flag`).not.toContain("--allow-insecure-http"); + }); + }); + } +});