Paseo 페어링이 timeout 날 때, 릴레이 장애부터 의심하지 마라

셀프호스팅 Paseo 데몬에 폰을 페어링하는데 Connection timed out. 릴레이가 죽었나 싶지만 대개는 Server ID 불일치다. 릴레이를 의심하기 전에 봐야 할 진단 순서.

셀프호스팅한 Paseo 데몬에 폰을 붙이려고 QR 을 스캔했다. 잠깐 도는가 싶더니 Connection timed out. 릴레이(relay.paseo.sh)가 살아있는지부터 의심했다. 사내망이 outbound 443 을 막나, 릴레이 서버가 죽었나. 전부 아니었다. 원인은 훨씬 시시했다 — QR 이 가리키는 데몬과 내가 띄우려던 데몬이 서로 다른 놈이었다.

이 글은 그 오진을 피하려고 정리한 진단 순서다. 페어링 타임아웃에서 릴레이를 의심하기 전에 확인할 것들.

페어링 경로부터 이해하기

Paseo 클라이언트(폰·PC 앱)가 셀프호스팅 데몬에 붙는 기본 경로는 릴레이 페어링이다. 데몬이 relay.paseo.sh:443 으로 outbound wss 를 열어두고, 클라이언트도 같은 릴레이에 접속해 Server ID 로 상대 데몬을 찾는다. 도메인·리버스 프록시·DNS 없이 outbound 443 만 뚫려 있으면 붙는다.

여기서 핵심은 클라이언트가 IP 나 호스트로 데몬을 찾는 게 아니라 Server ID 로 릴레이에 조회한다는 점이다. 그래서 이 ID 가 어긋나면 네트워크가 멀쩡해도 상대를 못 찾고 타임아웃난다.

증상 — 릴레이는 살아있는데 타임아웃

재현 조건은 단순하다. 릴레이 소켓이 ESTABLISHED 이고 사내망도 안 막는데, QR 스캔만 하면 폰이 Connection timed out 을 뱉는다.

릴레이 자체는 붙어 있는지 먼저 확인한다. 설정값(daemon status 의 Relay 항목)이 아니라 실제 소켓을 봐야 한다.

# 데몬 컨테이너 안에서 실제 ESTABLISHED 인 443 소켓 찾기
docker exec paseo sh -lc 'cat /proc/net/tcp' | awk 'NR>1{print $3,$4}'
#   XXXXXXXX:01BB 01   ← 뒤의 01 = ESTABLISHED, 01BB(hex) = 443

또는 로그에서:

grep -c relay_control_connected ~/.paseo/daemon.log   # 0 이 아니면 붙은 것

이게 잡히면 릴레이는 범인이 아니다. hub statusnot_connected 로 나와도 겁먹지 말 것 — 그건 Paseo Hub(별개의 클라우드 협업 기능)이고 릴레이 페어링과 무관하다. 여기서 헷갈려서 릴레이 삽질로 빠지기 쉽다.

실패했던 접근 — 릴레이·네트워크 파기

처음엔 릴레이 장애를 가정하고 outbound 443 도달성, 방화벽, DNS 를 한참 팠다. 데몬을 재시작해보고 릴레이 엔드포인트도 확인했다. 전부 정상이었다. 릴레이 소켓이 ESTABLISHED 인 걸 확인하고 나서야 방향을 틀었다 — 문제는 “붙는가”가 아니라 “누가 붙어 있는가”였다.

근본 원인 — Server ID 불일치

폰이 릴레이에 조회하는 ID 와, 실제 릴레이에 등록된 데몬의 ID 를 대조한다. 정상이면 두 값이 같다.

paseo daemon status | grep "Server ID"
#   Server ID   srv_aaaaaaaaaaaa        ← 로컬에 저장된 ID

paseo daemon pair --json | python3 -c "
import json,sys,base64
d=json.load(sys.stdin)
o=d['url'].split('offer=')[1]; o+='='*(-len(o)%4)
print(json.loads(base64.urlsafe_b64decode(o))['serverId'])"
#   srv_bbbbbbbbbbbb        ← QR 이 폰에게 '이 ID 를 찾아라' 시키는 값

두 값이 다르면, 폰은 릴레이에 등록되지 않은 ID 를 조회하고 아무 응답도 못 받아 타임아웃난다. 릴레이 장애처럼 보이지만 아니다.

왜 두 값이 갈리나

출처가 다르기 때문이다.

  • daemon status 의 Server ID → 로컬 $PASEO_HOME/server-id 파일을 읽는다. 불변.
  • pair --json 의 serverId → CLI 가 실제로 붙어 있는 데몬에게 물어서 받는다.

그래서 CLI 가 내가 의도한 데몬이 아니라 다른 데몬에 붙어 있으면 두 값이 갈린다. 신뢰할 쪽은 pair 다 — QR 에 실제로 실리는 건 그 값이다.

전형적인 발생 상황은 데스크톱 앱 + 별도 CLI 데몬을 같은 머신에서 돌릴 때다. 예를 들어 Windows 앱과 WSL CLI 조합:

  1. Paseo 데스크톱 앱이 이미 기본 포트 6767 을 점유
  2. CLI 에서 paseo onboard 를 돌리면, 포트가 차 있으니 자기 데몬을 안 띄우고 앱 데몬에 그냥 붙는다
  3. QR 은 앱 데몬의 ID 로 발급되는데, 정작 에이전트를 돌리고 싶은 건 WSL 데몬

즉 “내 데몬을 페어링했다”고 생각하지만 실제로는 남의 데몬 QR 을 스캔한 것이다.

실제 해결 — 포트를 비우고 원하는 데몬을 띄운다

의도한 데몬 하나만 그 포트를 잡게 만든다.

# 1. 점유하던 앱을 트레이까지 완전 종료
# 2. 포트가 비었는지 확인
ss -lntp | grep 6767        # 아무것도 안 나와야 함
# 3. 원하는 데몬 기동
paseo daemon start
# 4. 두 ID 가 일치하는지 확인
paseo daemon status | grep "Server ID"
paseo daemon pair --json | python3 -c "import json,sys,base64;d=json.load(sys.stdin);o=d['url'].split('offer=')[1];o+='='*(-len(o)%4);print(json.loads(base64.urlsafe_b64decode(o))['serverId'])"
# 5. 일치하면 QR 재발급 후 다시 스캔

daemon startEADDRINUSE: address already in use 127.0.0.1:6767 로 죽으면 아직 누가 물고 있는 것이다. 이때 다른 OS/호스트부터 의심하지 말고, 어느 프로세스가 잡았는지를 먼저 본다. supervisor 가 node 워커를 cluster 로 돌려서 부모만 죽어도 자식 워커가 포트를 물고 남을 수 있다.

paseo daemon stop --force
ps -ef | grep '[p]aseo'     # 잔재 워커 확인 후 정리

CLI 가 어느 데몬에 붙었는지 판별

daemon statusProviders 경로가 가장 확실한 단서다.

Claude   available (daemon)          ← 경로 없음. 다른(원격) 데몬에 붙어 있다
Claude   /home/me/.local/bin/claude  ← 이 CLI 와 같은 머신의 데몬이다

Home·Hostname 은 근거가 못 된다 — Home 은 CLI 자신의 PASEO_HOME 이고, WSL 같은 환경은 호스트명이 호스트 OS 와 겹친다. ss -tnp | grep :443 으로 릴레이 소켓을 찾는 것도, 데몬이 다른 네트워크 네임스페이스(예: Windows 쪽)에 있으면 이쪽엔 애초에 안 잡히므로 소켓이 없다고 “릴레이 실패”로 단정하면 안 된다.

클라이언트에 저장된 신원도 stale 이 된다

데몬을 갈아치운 뒤에는 또 다른 함정이 있다. 예전에 붙여둔 클라이언트가 옛 Server ID 를 저장한 채 물고 있는 경우다. 접속 주소(localhost:6767 등)가 맞아도 저장된 신원이 다르면 핸드셰이크가 안 끝나고 UI 는 Connecting 에서 무한 대기한다 — 인증 실패도, 타임아웃도 안 뜬다.

해결은 항목 편집이 아니라 삭제 후 재생성이다(편집은 옛 신원이 남을 수 있다). 가장 확실한 건 폰에 쓴 것과 같은 페어링 링크를 그대로 붙여넣는 것 — 링크(https://app.paseo.sh/#offer=<base64url>)에 serverId + 공개키가 들어 있어 신원이 자동으로 맞춰진다.

세 줄로 확정한다 — status 와 pair 의 ID 대조

세 줄이면 확정된다.

paseo daemon status | grep "Server ID"     # (A) 로컬 저장 ID
paseo daemon pair --json | python3 -c "import json,sys,base64;d=json.load(sys.stdin);o=d['url'].split('offer=')[1];o+='='*(-len(o)%4);print(json.loads(base64.urlsafe_b64decode(o))['serverId'])"   # (B) 실제 붙은 데몬 ID

(A) == (B) 이면 CLI 가 내가 의도한 데몬을 보고 있는 것이고, 그 QR 은 올바른 데몬을 가리킨다. 여기까지 맞고 릴레이 소켓도 ESTABLISHED 면 폰 스캔은 붙는다. 여전히 안 붙으면 그때 클라이언트 저장 신원 stale 을 의심한다(항목 삭제 후 링크 재입력).

데몬이 여럿이면 포트를 갈라라

  • 여러 데몬이 한 머신에 공존하는 구성이면 포트를 갈라라. 앱은 기본 포트, CLI 데몬은 다른 포트로. 안 그러면 매번 “앱을 완전 종료”라는 수동 회피를 반복해야 한다. ~/.paseo/config.jsondaemon.listen127.0.0.1:6777 같은 다른 포트로 바꾸면 된다.
  • 네트워크 네임스페이스를 공유하는 환경(예: WSL networkingMode=mirrored)에서는 양쪽 127.0.0.1:6767 이 같은 소켓 공간이라 데몬 둘이 애초에 공존 못 한다. 포트 분리가 선택이 아니라 필수다.
  • QR 이 아니라 링크만 쓰는 경로(TTY 없는 CI·에이전트 하네스)에서는 pair --jsonurl 필드를 그대로 쓰면 된다. serverId 검증도 그 JSON 하나로 끝난다.
  • pair 는 최상위 명령이 아니다. paseo pair 가 아니라 paseo daemon pair 다. paseo onboard 도 페어링을 출력하지만 first-time setup 을 겸해 상태를 건드리므로, 이미 도는 데몬엔 daemon pair 를 쓴다.

정리하면, Paseo 페어링 타임아웃의 첫 용의자는 릴레이가 아니라 Server ID 불일치다. 릴레이 소켓이 ESTABLISHED 인지 한 줄로 확인하고, statuspair 의 ID 를 대조하는 것 — 이 두 단계가 릴레이·네트워크를 파헤치는 삽질의 대부분을 없앤다.