---
title: 콜사인 통신 규약 — callsign-wire/1
summary: 어떤 OS·언어로든 허브에 기기를 붙이고 부를 수 있게 하는 JSON-over-WebSocket/HTTPS 규약
audience: [human, agent]
applies_to: [macos, linux, windows, android, ios, raspberry-pi, any]
requires: [WebSocket(RFC 6455), JSON(RFC 8259), HTTPS]
commands:
  - csn fabric commission <이름>
  - callsign-agent join <허브> <코드>
  - csn api GET nodes
updated: 2026-11-03
---

# 콜사인 통신 규약 — `callsign-wire/1`

**TL;DR**
- 전송은 **WebSocket 위 UTF-8 JSON 텍스트 프레임** 하나뿐이다. 바이너리 프레임·전용 직렬화·OS API 가 없다 —
  WebSocket 과 JSON 을 다루는 언어라면 어디서든(마이크로컨트롤러 포함) 노드를 새로 짤 수 있다.
- 노드는 **밖으로 걸기만 한다**(허브 → 노드 인바운드 포트 0개). 방화벽·NAT·통신사 망 뒤에서도 붙는다.
- 메시지는 다섯 가지: 노드 → 허브 `hello`·`beat`·`attr`·`endpoints`·`result`, 허브 → 노드 요청 `{id, verb, args}` 하나.
- 동사는 Matter 에서 빌린 넷: `read`·`write`·`invoke`·`subscribe`. 기기마다 다른 일은 `invoke` 의 **클러스터 이름**(`shell.run`·`screen.shot`…)으로만 갈린다.

규약을 지키는 실제 구현: 노드 `agent-rs/src/main.rs`(`session`)·`agent-rs/src/proto.rs`, 허브 `server/src/adapters/http_fleet.rs`(`line`)·`server/src/domain/fleet.rs`.
이 문서와 코드가 어긋나면 **코드가 맞다** — 문서를 고쳐라.

## 1. 왜 이 모양인가

| 결정 | 이유 |
| --- | --- |
| WebSocket + JSON | 모든 OS·언어에 표준 라이브러리가 있다. 맥·윈도우·리눅스·안드로이드·iOS·ESP32 가 같은 글을 읽는다 |
| 노드가 바깥으로 건다 | 기기에 포트를 열지 않는다. 공유기·LTE·회사망 뒤에서도 그대로 |
| 열쇠는 `Authorization` 머리글로만 | 주소(`?token=`)에 실으면 프록시·접근 기록에 남는다 |
| 기기마다 열쇠 하나, 이름에 묶음 | 열쇠 하나가 새도 그 기기만 끊으면 된다. 남의 이름으로는 못 붙는다 |
| 동사 넷 + 클러스터 이름 | 새 기기가 새 동사를 만들 필요가 없다. 모르는 클러스터는 "모른다"고 답하면 된다 |
| 텍스트 한 줄 = 메시지 하나 | 조각 맞추기·길이 머리가 없다. 디버깅은 `websocat` 하나로 된다 |

## 2. 주소와 인증

```
노드 줄:   wss://<허브>/api/fleet/ws         (테일넷·LAN 안이면 ws://<허브>:8788/api/fleet/ws)
부르는 쪽: https://<허브>/api/fleet/*         (HTTP JSON)
머리글:    Authorization: Bearer <열쇠>
```

열쇠는 세 가지다.

| 열쇠 | 누가 쥐나 | 여는 것 |
| --- | --- | --- |
| 기기 열쇠 | 노드 하나 | 그 노드 이름으로 `/api/fleet/ws` 에 붙기만. 다른 이름으로 `hello` 하면 허브가 끊는다 |
| 조작 열쇠 | CLI·에이전트(`csn`) | `/api/fleet/call`·`nodes`·`events` 등 부르기 |
| 마스터 토큰 | 허브 주인 | 전부. 단, 터널을 거친 줄로는 노드 행세를 못 한다 |

### 2.1 설정 코드로 들이기(마스터 토큰을 기기에 복사하지 않는다)

```
허브 주인:  csn fabric commission newpi        → 8자리 코드(10분·한 번)
새 기기:    POST https://<허브>/api/fleet/join
            {"code":"12345678","node":"newpi","os":"linux"}
허브 답:    200 {"node":"newpi","token":"<기기 열쇠>"}
```

기기는 받은 열쇠를 자기 설정(`~/.callsign-agent/agent.json`, 권한 0600)에 적고 그 열쇠로 WebSocket 을 연다.
`callsign-agent join <허브> <코드>` 가 이 일을 한다(`agent-rs/src/join.rs`). 회수는 허브에서 그 줄을 지우면 끝 — 이 기기만 끊긴다.

## 3. 노드 줄(WebSocket)

### 3.1 순서

```
노드                                    허브
 │── (TLS + Upgrade, Authorization) ──▶ │   열쇠 검사. 모르면 401
 │── hello ───────────────────────────▶ │   첫 프레임은 반드시 hello. 아니면 {"error":…} 후 닫음
 │── beat (10초마다) + WS Ping ────────▶ │   30초(3번) 못 들으면 죽은 노드
 │── attr (값이 바뀔 때만) ─────────────▶ │
 │◀────────────────────── {id,verb,args} │   일 시키기(여러 개가 동시에 올 수 있다)
 │── {m:"result",id,ok,res|error,ms} ──▶ │   id 로 짝을 맞춘다. 순서는 상관없다
```

- 모든 프레임은 **텍스트 프레임 하나에 JSON 객체 하나**다. 메시지·프레임 상한은 64 MiB(파일 조각이 base64 로 실린다).
- 노드는 요청을 **동시에** 처리해도 된다. 허브는 `id` 로만 짝을 맞춘다.
- 노드가 30초 동안 프레임(Pong 포함)을 하나도 못 받으면 줄이 죽은 것으로 보고 다시 건다(와이파이를 갈아타면 TCP 는 15분을 버틴다).
- 같은 이름이 새 줄로 붙으면 허브는 옛 줄을 닫는다. 다시 붙을 때 `hello` 부터 새로 한다.

### 3.2 `hello` — 노드 → 허브, 첫 프레임

```json
{
  "node": "pi",
  "os": "linux",
  "caps": ["shell", "files", "gpio"],
  "clusters": ["shell.run", "file.get", "file.put", "gpio.read", "gpio.write", "sys.info"],
  "taints": [],
  "endpoints": [{"id": 1, "type": "ir-bridge", "clusters": ["onoff"]}]
}
```

| 키 | 필수 | 뜻 |
| --- | --- | --- |
| `node` | ✓ | 콜사인(노드 이름). 영문·숫자·`-`·`_` 64자까지. `.`·`/` 는 쓰지 않는다(CLI 주소 `node/끝점.클러스터.속성` 을 가른다) |
| `os` | ✓ | `macos`·`windows`·`linux`·`android`·`ios` 또는 새 이름(소문자). 허브는 값을 해석하지 않고 고르기(`--os`)에만 쓴다 |
| `caps` | | 능력 라벨(`screen`·`shell`·`hid`·`gpu`·`matter`·`adb`…). 허브의 `-r <cap>` 고르기에 쓰인다 |
| `clusters` | | 알아듣는 `invoke` 클러스터. 비면 거르지 않는다(옛 노드) |
| `taints` | | 지금 일을 받지 않을 사정(`low-battery`·`hot`·`busy`…) |
| `endpoints` | | 품은 장치(끝점 1 이상). 없으면 끝점 0(노드 자신)만 |

### 3.3 노드 → 허브

| `m` | 모양 | 언제 |
| --- | --- | --- |
| `beat` | `{"m":"beat","taints":["low-battery"]}` | 10초마다. taint 를 늘 통째로 보낸다 |
| `attr` | `{"m":"attr","attrs":{"battery":81,"idle_min":3},"endpoint":0}` | 속성이 바뀔 때. `endpoint` 없으면 0 |
| `endpoints` | `{"m":"endpoints","endpoints":[…]}` | 품은 장치 목록이 바뀔 때(다시 `hello` 하지 않는다) |
| `result` | `{"m":"result","id":17,"ok":true,"res":{…},"ms":12.3}` | 요청 하나의 답 |
| `result`(실패) | `{"m":"result","id":17,"ok":false,"error":"…"}` | 모르는 클러스터·거부·실패. 사람이 읽을 글로 |

모르는 `m` 은 허브가 버린다 — 새 메시지를 더해도 옛 허브가 죽지 않는다.

### 3.4 허브 → 노드

```json
{"id": 17, "verb": "invoke", "args": {"cluster": "shell.run", "cmd": "uptime", "timeout": 30}}
```

| 키 | 뜻 |
| --- | --- |
| `id` | 허브가 매기는 수. 답에 그대로 돌려준다 |
| `verb` | `read`·`write`·`invoke`·`subscribe` |
| `args` | 동사별 인자. `endpoint`(수)가 있으면 그 끝점에, 없으면 노드 자신에 |
| `error` | `id` 없이 이것만 오면 허브의 알림(예: `"이 열쇠는 pi 전용입니다"`). 답하지 않는다 |

## 4. 동사

| 동사 | `args` | 답 `res` |
| --- | --- | --- |
| `read` | `{"attr":"battery"}` 또는 `{}` | `{"battery":81}` 또는 속성 전체 |
| `write` | `{"attr":"screen_off_min","value":5}` | 쓴 뒤 값. 보기 전용 노드는 거부 |
| `invoke` | `{"cluster":"<이름>", …}` | 클러스터마다 다르다(§5) |
| `subscribe` | `{"attr":"temp.value","min_s":1,"max_s":10}` | `{"subscribed":{…}}`. 이후 값은 `attr` 로 온다 |

## 5. 클러스터(`invoke`) — 모든 OS 공통 이름

노드는 할 수 있는 것만 `hello.clusters` 에 싣는다. 이름은 같고 OS 마다 속만 다르다
(`screen.shot` 은 맥에선 ScreenCaptureKit, 윈도우에선 GDI, 리눅스에선 `grim`, 폰에선 adb/WDA).

| 묶음 | 클러스터 | 대표 인자 → 답 |
| --- | --- | --- |
| 셸 | `shell.run` | `cmd`, `timeout`, `shell`(`sh`·`bash`·`zsh`·`powershell`), `cwd`, `bg` → `{code,out,err}` |
| 화면 | `screen.shot`·`screen.state`·`window.list` | `region`, `scale` → `{png\|jpg(base64), bytes, screen}` |
| 입력 | `input.click`·`input.move`·`input.type`·`input.key`·`input.scroll`·`input.batch` | `x`,`y` / `text` / `key` / `steps[]` |
| 파일 | `file.get`·`file.put`·`file.list`·`file.stat` | `path`, `data`(base64), `offset` |
| 클립보드 | `clip.get`·`clip.set` | `text` |
| 시스템 | `sys.info`·`sys.health`·`proc.list`·`proc.kill`·`power.lock`·`net.wol`·`log.tail` | |
| 작업 | `job.list`·`job.log` | `shell.run bg` 로 떼어 둔 일 |
| 장치 | `gpio.read`·`gpio.write`·`gpu.info`·`matter.cmd` | |
| 시뮬레이터 | `sim.*`(iOS)·`emu.*`(안드로이드) | |
| 권한 | `perm.check`·`perm.request` | 맥 TCC 등 |
| 에이전트 | `agent.update`·`agent.restart`·`agent.rekey` | 자기 갱신·열쇠 교체 |

새 클러스터는 `묶음.동작` 꼴로 이름 짓고, 모르는 이름이 오면 **아는 목록을 붙여** 실패로 답한다.
노드 설정의 `deny`(클러스터 글롭)에 걸린 것은 "모른다"가 아니라 "거부한다"고 답한다.

## 6. 부르는 쪽(HTTP) — `POST /api/fleet/call`

CLI(`csn`)·에이전트·스크립트는 노드 줄을 직접 열지 않고 허브에 HTTP 로 부탁한다.

```http
POST /api/fleet/call
Authorization: Bearer <조작 열쇠>
Content-Type: application/json

{"requires":["gpu"], "os":"linux", "tolerate":["hot"], "selector":{"room":"lab"},
 "verb":"invoke", "args":{"cluster":"shell.run","cmd":"nvidia-smi"},
 "wait_s":30, "timeout_s":90, "all":false}
```

| 키 | 뜻 |
| --- | --- |
| `node` | 이름을 찍으면 그 노드만 |
| `requires`·`os`·`tolerate`·`selector` | 고르기(쿠버네티스의 nodeSelector·toleration·label selector) |
| `wait_s` | 맞는 노드가 없을 때 기다릴 초(Pending). 0 이면 바로 409 |
| `timeout_s` | 노드 답 상한(기본 90 — 클라우드플레어가 100초에 끊는다) |
| `all` | 맞는 노드 **모두**에게 |

답은 노드의 `result` 를 그대로 싣는다. 줄이 없던 것(`noline`, 다른 노드로 넘겨도 안전)과 보낸 뒤 끊긴 것(`dropped`, 돌았을 수 있음)을 구분해 알린다.

그 밖의 노선: `GET /api/fleet/nodes`(목록)·`status`·`events`(SSE)·`audit`·`cordon`·`claim`·`join`.

## 7. 새 OS 에 노드를 짜는 최소 구현

아래 다섯 가지만 하면 `csn ls` 에 뜨고 `csn sh <노드> -- …` 가 돈다. 파이썬 40줄이면 된다.

1. `wss://<허브>/api/fleet/ws` 에 `Authorization: Bearer <기기 열쇠>` 로 붙는다.
2. `hello` 를 보낸다(`clusters: ["shell.run"]`).
3. 10초마다 `{"m":"beat","taints":[]}` 와 WebSocket Ping 을 보낸다.
4. `{id, verb:"invoke", args:{cluster:"shell.run", cmd}}` 를 받으면 돌리고 `{"m":"result","id":…,"ok":true,"res":{"code":0,"out":"…","err":""}}` 로 답한다.
5. 줄이 끊기면 1~5초 뒤 처음부터 다시 붙는다.

```python
import asyncio, json, subprocess, websockets   # pip install websockets

HUB, KEY, NODE = "wss://hub.example.com/api/fleet/ws", "<기기 열쇠>", "mybox"

async def main():
    while True:
        try:
            async with websockets.connect(HUB, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws:
                await ws.send(json.dumps({"node": NODE, "os": "linux", "caps": ["shell"], "clusters": ["shell.run"], "taints": []}))
                async def beat():
                    while True:
                        await ws.send(json.dumps({"m": "beat", "taints": []})); await asyncio.sleep(10)
                asyncio.create_task(beat())
                async for raw in ws:
                    r = json.loads(raw)
                    if "id" not in r: continue
                    a = r.get("args") or {}
                    if r.get("verb") == "invoke" and a.get("cluster") == "shell.run":
                        p = subprocess.run(a["cmd"], shell=True, capture_output=True, text=True, timeout=a.get("timeout", 120))
                        out = {"m": "result", "id": r["id"], "ok": True, "res": {"code": p.returncode, "out": p.stdout, "err": p.stderr}}
                    else:
                        out = {"m": "result", "id": r["id"], "ok": False, "error": f"{NODE} 는 {a.get('cluster')} 를 모릅니다. 아는 것: shell.run"}
                    await ws.send(json.dumps(out))
        except Exception:
            await asyncio.sleep(3)

asyncio.run(main())
```

## 8. 호환 규칙

- **더하기만 한다.** 키를 새로 더할 수는 있어도 있던 키의 뜻을 바꾸거나 빼지 않는다. 받는 쪽은 모르는 키를 무시한다.
- 빈 배열·없는 키는 같은 뜻이다(`endpoints` 가 없으면 끝점 0 만).
- 버전 번호를 주고받지 않는다 — 능력은 `caps`·`clusters` 로 알린다. 뜻이 깨지는 변경이 필요하면 노선을 새로 연다(`/api/fleet/ws2`).
