Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ decade 문서(020-060) 작성 중 올라온 계약 편차와 열린 질문에
| K16 | status/candidates 응답 모양 (050) | `GET /api/link/status` → `{role: "standalone"|"home"|"child", listener: {state: "off"|"listening"|"failed", port: number|null}, links: [{id, alias, direction, state: "connecting"|"connected"|"reconnecting"|"failed"|"idle", since: string, reason: string|null, tunnelPort: number}], child: null | {alias, state, since, reason}}`. `GET /api/link/candidates` → `{candidates: [{alias, source: "ssh_config"|"tailscale"}]}`. `POST /api/link/probe` → `{alias, fingerprint, keyType}`. `POST /api/link/confirm-host` → `{alias, fingerprint, ocxVersion}`. `POST /api/link/apply` → 202 `{linkId}` 후 status 폴링. 오류는 `{error: {code, message}}` | wp4, wp5, wp6 |
| K17 | GUI locale 수 (050) | gui/src/i18n의 실제 locale 파일 전부(050 확인: 10개). docs-site는 영어 원문 + 기존 번역 locale에 같은 페이지를 추가하되 번역이 없으면 영어 원문 링크를 두지 않고 해당 locale 생략 | wp5 |
| K18 | link 데이터 키 메모리 제로화 (wp3 감사 Leibniz) | 요구하지 않는다. JS 문자열은 지울 수 없고 키는 0600 토큰 파일에 저장되는 장기 비밀이며 기존 hub connect와 같은 처리다. 대신 stdin 4 KiB 상한, 원본 입력 버퍼 0 채움, 키가 로그·오류·config·journal·status에 나타나지 않음을 테스트로 고정 | wp3, wp6 |
| K19 | D13 "failed는 조치 필요"를 Child가 소유한 터널에도 적용할지 (PR-D, 자동 복구 요구) | Child가 소유한 `-L` 터널에만 재시도 정책을 둔다. timeout·forward는 약 60초마다, auth는 5분마다(시간당 최대 12회) 다시 시도하고, 바뀐 호스트 키는 계속 failed로 남는다. 재시도 중에도 상태는 failed로 보인다. 재연결은 링크 키를 담은 `/readyz`가 링크를 증명할 때만 connected로 올린다. 200, 또는 `service: "opencodex"` 본문을 가진 503이다(Home의 link listener는 `/readyz` 앞에서 401을 주므로 이 503은 Home 자체 startup readiness가 pending/failed라는 뜻일 뿐이고 표시용 `home_not_ready`로 남긴다). Home의 `-R` supervisor는 바꾸지 않는다(PR-F 몫) | wp6 |
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/fr/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Le rôle **Child** n’est disponible que lorsque OpenCodex tourne sur son port
## État de la liaison

- **Connected** signifie que le tunnel SSH est prêt et que Child peut utiliser la liaison Home.
- **Reconnecting** signifie que le tunnel est réessayé. Les requêtes peuvent temporairement renvoyer `503` avec `Retry-After`.
- **Failed** signifie que la liaison nécessite une intervention. Vérifiez l’authentification SSH, la clé d’hôte confirmée, la redirection ou le délai indiqué.
- **Reconnecting** signifie que le tunnel est réessayé. Les requêtes peuvent temporairement renvoyer `503` avec `Retry-After`. Sur un Child connecté depuis son propre tableau de bord, une requête attend d’abord jusqu’à 15 secondes le retour du tunnel.
- **Failed** signifie que la liaison nécessite une intervention. Vérifiez l’authentification SSH, la clé d’hôte confirmée, la redirection ou le délai indiqué. Un Child connecté depuis son propre tableau de bord réessaie de lui-même après une mise en veille, une panne ou un redémarrage : environ une fois par minute après un délai dépassé ou une erreur de redirection, et toutes les cinq minutes après une erreur d’authentification. Une clé d’hôte modifiée n’est jamais réessayée.

Une liaison en échec ne bascule pas silencieusement vers un fournisseur local.

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ The **Child** role is available only while OpenCodex runs on its configured port
## Link status

- **Connected** means the SSH tunnel is ready and the Child can use the Home link.
- **Reconnecting** means the tunnel is being retried. Requests can temporarily return `503` with `Retry-After` while the retry is in progress.
- **Failed** means the link needs attention. Check SSH authentication, the confirmed host key, forwarding, or the timeout reason shown in the dashboard.
- **Reconnecting** means the tunnel is being retried. Requests can temporarily return `503` with `Retry-After` while the retry is in progress. On a Child that connected from its own dashboard, a request first waits up to 15 seconds for the tunnel to come back.
- **Failed** means the link needs attention. Check SSH authentication, the confirmed host key, forwarding, or the timeout reason shown in the dashboard. A Child that connected from its own dashboard keeps retrying by itself, after sleep, an outage or a restart: about once a minute after a timeout or forwarding error, and every five minutes after an authentication error. A changed host key is never retried.

A failed link does not silently switch to a local provider.

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ja/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Home のプロバイダーを使うコンピューターで次の操作を行い
## リンクの状態

- **Connected** は SSH トンネルが準備でき、Child が Home のリンクを使える状態です。
- **Reconnecting** はトンネルを再試行している状態です。再試行中はリクエストが `Retry-After` 付きの `503` を一時的に返すことがあります。
- **Failed** は対応が必要な状態です。SSH 認証、確認済みのホストキー、転送、タイムアウトの理由を確認してください。
- **Reconnecting** はトンネルを再試行している状態です。再試行中はリクエストが `Retry-After` 付きの `503` を一時的に返すことがあります。自分のダッシュボードから接続した Child では、リクエストはまずトンネルの復帰を最大 15 秒待ちます。
- **Failed** は対応が必要な状態です。SSH 認証、確認済みのホストキー、転送、タイムアウトの理由を確認してください。自分のダッシュボードから接続した Child は、スリープ、障害、再起動の後も自動で再試行します。タイムアウトや転送エラーの後は約 1 分ごと、認証エラーの後は 5 分ごとです。ホストキーが変わった場合は再試行しません。

リンクが失敗しても、ローカルプロバイダーへ自動的に切り替わることはありません。

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ko/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다.
## 링크 상태

- **Connected**는 SSH 터널이 준비되어 Child가 Home 링크를 사용할 수 있다는 뜻입니다.
- **Reconnecting**은 터널을 다시 연결하는 중이라는 뜻입니다. 재시도 중에는 요청이 일시적으로 `Retry-After`와 함께 `503`을 반환할 수 있습니다.
- **Failed**는 조치가 필요하다는 뜻입니다. SSH 인증, 확인한 호스트 키, 포워딩 또는 타임아웃 사유를 확인하세요.
- **Reconnecting**은 터널을 다시 연결하는 중이라는 뜻입니다. 재시도 중에는 요청이 일시적으로 `Retry-After`와 함께 `503`을 반환할 수 있습니다. 자기 대시보드에서 연결한 Child에서는 요청이 먼저 터널이 돌아오기를 최대 15초 기다립니다.
- **Failed**는 조치가 필요하다는 뜻입니다. SSH 인증, 확인한 호스트 키, 포워딩 또는 타임아웃 사유를 확인하세요. 자기 대시보드에서 연결한 Child는 절전, 장애, 재시작 뒤에도 스스로 다시 시도합니다. 타임아웃이나 포워딩 오류 뒤에는 약 1분마다, 인증 오류 뒤에는 5분마다 시도합니다. 호스트 키가 바뀐 경우에는 다시 시도하지 않습니다.

링크가 실패해도 로컬 프로바이더로 조용히 전환하지 않습니다.

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ru/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ SSH с паролем и Windows сейчас не поддерживаются.
## Состояние связи

- **Connected** означает, что SSH-туннель готов и Child может использовать связь Home.
- **Reconnecting** означает, что туннель переподключается. Во время повторных попыток запросы могут временно получать `503` с `Retry-After`.
- **Failed** означает, что связь требует действий. Проверьте SSH-аутентификацию, подтверждённый ключ хоста, перенаправление или причину тайм-аута.
- **Reconnecting** означает, что туннель переподключается. Во время повторных попыток запросы могут временно получать `503` с `Retry-After`. На Child, подключённом из собственной панели, запрос сначала до 15 секунд ждёт восстановления туннеля.
- **Failed** означает, что связь требует действий. Проверьте SSH-аутентификацию, подтверждённый ключ хоста, перенаправление или причину тайм-аута. Child, подключённый из собственной панели, сам повторяет попытки после сна, сбоя или перезапуска: примерно раз в минуту после тайм-аута или ошибки перенаправления и каждые пять минут после ошибки аутентификации. Изменённый ключ хоста никогда не повторяется.

При сбое связи система молча не переключается на локального провайдера.

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/tr/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Bağlanmak bu bilgisayardaki OpenCodex'i yeniden başlatır. Zaten çalışan Co
## Bağlantı durumu

- **Connected**, SSH tünelinin hazır ve Child'ın Home bağlantısını kullanabilir olduğu anlamına gelir.
- **Reconnecting**, tünelin yeniden denendiği anlamına gelir. Yeniden deneme sırasında istekler geçici olarak `Retry-After` ile birlikte `503` döndürebilir.
- **Failed**, bağlantının ilgilenilmesi gerektiği anlamına gelir. SSH kimlik doğrulamasını, onaylanan ana bilgisayar anahtarını, yönlendirmeyi veya zaman aşımı nedenini kontrol edin.
- **Reconnecting**, tünelin yeniden denendiği anlamına gelir. Yeniden deneme sırasında istekler geçici olarak `Retry-After` ile birlikte `503` döndürebilir. Kendi panosundan bağlanan bir Child üzerinde istek önce tünelin geri gelmesi için en fazla 15 saniye bekler.
- **Failed**, bağlantının ilgilenilmesi gerektiği anlamına gelir. SSH kimlik doğrulamasını, onaylanan ana bilgisayar anahtarını, yönlendirmeyi veya zaman aşımı nedenini kontrol edin. Kendi panosundan bağlanan bir Child; uyku, kesinti veya yeniden başlatmadan sonra kendiliğinden yeniden dener: zaman aşımı veya yönlendirme hatasından sonra yaklaşık dakikada bir, kimlik doğrulama hatasından sonra beş dakikada bir. Değişmiş bir ana bilgisayar anahtarı asla yeniden denenmez.

Bağlantı başarısız olduğunda sistem sessizce yerel bir sağlayıcıya geçmez.

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/zh-cn/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ description: 通过 SSH 将 OpenCodex 主机与子机连接起来。
## 链接状态

- **Connected** 表示 SSH 隧道已就绪,子机可以使用主机链接。
- **Reconnecting** 表示正在重试隧道。重试期间请求可能暂时返回带有 `Retry-After` 的 `503`。
- **Failed** 表示链接需要处理。请检查 SSH 身份验证、已确认的主机密钥、转发或超时原因。
- **Reconnecting** 表示正在重试隧道。重试期间请求可能暂时返回带有 `Retry-After` 的 `503`。在从自己的仪表板连接的 Child 上,请求会先最多等待 15 秒让隧道恢复。
- **Failed** 表示链接需要处理。请检查 SSH 身份验证、已确认的主机密钥、转发或超时原因。从自己的仪表板连接的 Child 会在睡眠、故障或重启后自动重试:超时或转发错误后大约每分钟一次,身份验证错误后每五分钟一次。主机密钥变更时不会重试。

链接失败时不会静默切换到本地提供商。

Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/zh-tw/guides/remote-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ description: 透過 SSH 連接 OpenCodex Home 電腦與 Child 電腦。
## 連結狀態

- **Connected** 表示 SSH 通道已準備好,Child 可以使用 Home 連結。
- **Reconnecting** 表示正在重試通道。重試期間請求可能暫時回傳帶有 `Retry-After` 的 `503`。
- **Failed** 表示連結需要處理。請檢查 SSH 驗證、已確認的主機金鑰、轉送或逾時原因。
- **Reconnecting** 表示正在重試通道。重試期間請求可能暫時回傳帶有 `Retry-After` 的 `503`。在從自己的儀表板連線的 Child 上,請求會先最多等待 15 秒讓通道恢復。
- **Failed** 表示連結需要處理。請檢查 SSH 驗證、已確認的主機金鑰、轉送或逾時原因。從自己的儀表板連線的 Child 會在睡眠、故障或重新啟動後自動重試:逾時或轉送錯誤後大約每分鐘一次,驗證錯誤後每五分鐘一次。主機金鑰變更時不會重試。

連結失敗時不會靜默切換到本機供應商。

Expand Down
25 changes: 22 additions & 3 deletions src/client/link-join.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomBytes } from "node:crypto";
import { hostname } from "node:os";
import { findAvailablePort } from "../server/ports";
import { isLinkPort } from "../link/ports";
import { isPortAvailable } from "../server/ports";
import { isLinkPort, JOIN_TUNNEL_PORT_MAX, JOIN_TUNNEL_PORT_MIN } from "../link/ports";
import { buildExecArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../link/ssh-argv";
import { sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../link/ssh-runner";
import { connectClient, type ClientConnectDeps } from "./connect";
Expand All @@ -24,6 +24,7 @@ const JOIN_TUNNEL_READY_TIMEOUT_MS = 15_000;
const JOIN_TUNNEL_POLL_MS = 100;
const JOIN_REVOKE_TIMEOUT_MS = 30_000;
const JOIN_CONFIRM_TTL_MS = 5 * 60_000;
const JOIN_PORT_ATTEMPTS = 32;
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
const DATA_KEY = /^ocx_data_[0-9a-f]{40}$/;
Expand Down Expand Up @@ -120,6 +121,24 @@ function parseIssuedLink(stdout: string): IssuedLink | null {
};
}

/**
* A free loopback port in the join range (`JOIN_TUNNEL_PORT_MIN`-`JOIN_TUNNEL_PORT_MAX`), tried at
* random so a fixed-port service on this computer is not hit every time.
*/
export async function chooseJoinTunnelPort(deps: {
isAvailable?: (port: number) => Promise<boolean>;
random?: () => number;
} = {}): Promise<number> {
const isAvailable = deps.isAvailable ?? (port => isPortAvailable(port, "127.0.0.1"));
const random = deps.random ?? Math.random;
const span = JOIN_TUNNEL_PORT_MAX - JOIN_TUNNEL_PORT_MIN + 1;
for (let attempt = 0; attempt < JOIN_PORT_ATTEMPTS; attempt += 1) {
const port = JOIN_TUNNEL_PORT_MIN + Math.min(span - 1, Math.floor(random() * span));
if (await isAvailable(port)) return port;
}
throw new Error("no free port in the join tunnel range");
}

function localAlias(deps: ClientLinkJoinDeps): string {
const raw = (deps.hostname ?? hostname)().trim();
const normalized = raw.replace(/[^A-Za-z0-9_\.\-]/g, "-").replace(/^-+/, "").slice(0, 253);
Expand Down Expand Up @@ -242,7 +261,7 @@ export async function joinHome(deps: ClientLinkJoinDeps, input: { alias: string
await compensateStaleSidecar(deps);
let tunnelPort: number;
try {
tunnelPort = await (deps.choosePort ?? (() => findAvailablePort(0, "127.0.0.1")))();
tunnelPort = await (deps.choosePort ?? (() => chooseJoinTunnelPort()))();
if (!isLinkPort(tunnelPort)) throw new Error("invalid link port");
} catch (error) {
void error;
Expand Down
Loading
Loading