docs(broker): establish remote Docker deployment plan for nats-server with networking/security guides and add D-15~D-21 freshness guards
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
# 🌐 원격 서버 `nats-server` Docker 프로덕션 배포 계획서 **Rev.2** (Job `b11d499d`)
|
||||
|
||||
- **역할**: Planner (`MULTI_AGENT_RULES.md` §1 — 저장소 코드/문서 미변경, 계획서만 산출)
|
||||
- **선행 리비전**: Rev.1 = Job `27236ab6`
|
||||
- **반영 챌린지**: Job `9a5cb88f` (`agy`) — `[VERDICT: PASS WITH CHALLENGE]`
|
||||
- **기준 커밋**: `c6b6c77` (Track 0 완료, **290 tests collected** 실측)
|
||||
- **검증 원칙**: 추론이 아닌 **실측**. 챌린지는 지시가 아니라 **가설**로 취급하여 재현·반증했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 0. Rev.2 판정 요약 (Adjudication)
|
||||
|
||||
| 챌린지 | 판정 | 요지 |
|
||||
|---|---|---|
|
||||
| **C1** 계정 격리가 교차 관측을 차단 | 🟡 **부분 인용 — 진단 유효, 귀속 부정확, 두 옵션 모두 결정적 한계 누락** | 계정 격리 사실은 맞음. 다만 "계획이 `HOME` 계정에서 MAM 이벤트 관측을 주장한다"는 귀속은 부정확 — `PRIVATE_SERVER.md:230`이 **정반대를 이미 처방**. 반면 내 Rev.1 config에 관측자 사용자가 **아예 없었던 것**은 실제 결함이므로 수용. **신규 실측**: retained 이벤트는 **MQTT 구독자에게만** 전달되므로 Option A/B 어느 쪽도 "사후 접속 대시보드가 종료 이벤트를 본다"를 만들지 못함 |
|
||||
| **C2** `_load_dotenv` 우선순위 | 🟢 **방향 수용 + 근본 결함 재정의 + 신규 결함 1건 발견** | 진짜 결함은 후보 목록이 아니라 **단일 후보 해석**. 그리고 `MAM_ENV_FILE`이 없는 파일을 가리키면 **다른 후보를 하나도 시도하지 않고 공개 브로커로 폴백**(실측) — 제안된 순서로는 고쳐지지 않음 |
|
||||
| **C3** `G-D5` 스코핑 | 🟡 **이미 Rev.1에 존재. 단 잔여 지적이 D-1을 강화** | 펜스 한정·오탐 부재 단언은 Rev.1 §6에 이미 명시. **신규 실측**: NATS 렉서는 **인용되지 않은 값에서만** `$VAR`를 해석 → `store_dir: "$HOME/..."`는 리터럴이며 인용 heredoc과 결합 시 **D-1과 동일하게 파손**. G-D5를 2항 검사로 강화 |
|
||||
|
||||
**Rev.2 실질 변경 6건**
|
||||
1. §A-1에 **`mam_observer` 읽기 전용 사용자**를 명시적으로 추가(Option B 채택 — 저장소 §5.5 처방과 일치).
|
||||
2. **retained는 MQTT 전용**이라는 신규 실측을 §1.9로 신설하고 §5.2 브리징 주장의 경계로 명문화.
|
||||
3. Option A(export/import)를 **예외 경로**로 문법 검증까지 마쳐 부록에 배치(무조건 채택하지 않는 근거 3건 첨부).
|
||||
4. `B-17` 처방을 **first-hit-wins 후보 목록**으로 재정의하고 `MAM_ENV_FILE` 조기 탈출 결함을 추가.
|
||||
5. **G-D5를 2항 검사로 강화**(값 + heredoc 구분자), `$VAR` 인용 규칙을 §1.1 각주에 정밀화.
|
||||
6. **G-D9 신설** — 문서 config 예제의 subject 리터럴과 `DEFAULT_TOPIC_ROOT` 일치 강제(M3 토픽 전환 시 조용한 파손 차단). 테스트 전망 290 → **297** → 298.
|
||||
|
||||
---
|
||||
|
||||
## 1. 사전 실측 결과 (Pre-Flight Measurements)
|
||||
|
||||
> Rev.1의 D-1 ~ D-5, H-1 ~ H-3은 챌린저가 "Verified 100% accurate"로 승인했습니다. 아래는 요지 유지 + **Rev.2 신규 실측 2건(§1.9, §1.10)** 및 §1.1 각주 정밀화입니다.
|
||||
|
||||
### 1.1 D-1 — `store_dir`의 `~`는 확장되지 않는다 (P1)
|
||||
|
||||
`PRIVATE_SERVER.md:73`, `:146`이 `store_dir: "~/.local/share/nats/data"`를 지시합니다.
|
||||
|
||||
**증거 1 — NATS 설정 파서에 틸드 확장 없음** (`server/opts.go`):
|
||||
```go
|
||||
case "store", "store_dir", "storedir":
|
||||
opts.StoreDir = mv.(string) // 문자열 그대로 대입. os.UserHomeDir 호출 없음
|
||||
```
|
||||
**증거 2 — 인용 heredoc이 셸 확장까지 차단** (실측): `<<'EOF'` → `store_dir: "~/.local/share/nats/data"` 리터럴 유지 / `<<EOF` → `/Users/godopu16/.local/share/nats/data` 전개. 리터럴 `~` 경로에 `mkdir -p` → CWD 아래 `./~` 디렉터리 생성.
|
||||
|
||||
**영향**: JetStream 스토리지가 `./~/.local/share/nats/data`에 생성됩니다. MQTT의 `$MQTT_rmsgs`(retained)·`$MQTT_sess`(세션)가 여기 있으므로, 다른 CWD에서 재기동하면 **retained 종료 이벤트가 통째로 사라집니다.**
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **각주 정밀화 (Rev.2, C3 파생)**: NATS 설정의 `$VAR` 참조는 **인용되지 않은 값에서만** 해석됩니다. 렉서 원문 — *"Check if the **unquoted** string is a variable reference, starting with `$`."* 이며 `lexQuotedString`은 *"It will not interpret any internal contents."* 입니다.
|
||||
> 따라서 **`store_dir: "$HOME/..."`는 리터럴 문자열**이며, 인용 heredoc과 결합하면 `./$HOME/.local/share/nats/data`가 만들어져 D-1과 **동일하게 파손**됩니다.
|
||||
> 규칙: `$HOME`은 **셸이 전개할 때만**(= 비인용 heredoc 안에서만) 허용. NATS가 해석해야 하는 변수(`password: $MAM_BROKER_PASS`)는 **따옴표를 씌우지 않습니다.**
|
||||
|
||||
### 1.2 D-2 — `nats:latest`는 scratch 변형이라 healthcheck를 넣을 수 없다 (P1)
|
||||
|
||||
`docker-library/official-images`의 `library/nats`: `SharedTags: 2.14.5, 2.14, 2, latest` @ `Directory: 2.14.x/scratch`. 해당 Dockerfile은 `FROM scratch` + `ENTRYPOINT ["/nats-server"]`. → 셸·wget·curl 부재로 **healthcheck 구현 불가**, 게다가 메이저 경계를 넘나드는 부동 태그.
|
||||
|
||||
**교정**: `image: nats:2.12-alpine`. alpine 엔트리포인트가 첫 인자 `-` 감지 시 `nats-server`를 자동 prepend하므로 `command: ["-c", ...]` 라인은 **양쪽 변형에서 동일 동작**(실측):
|
||||
```sh
|
||||
if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then set -- nats-server "$@"; fi
|
||||
```
|
||||
|
||||
### 1.3 D-3 — 무인증 모니터링 포트를 전 인터페이스에 게시 (P1)
|
||||
|
||||
현행 `PRIVATE_SERVER.md:120-124`가 `"8222:8222"`, `"8080:8080"`을 0.0.0.0에 게시합니다. NATS 공식 문서: *"The monitoring port is unauthenticated by default."* → `/varz`·`/connz`·`/jsz`·`/routez` 공개. 8080은 `no_tls: true` 평문.
|
||||
|
||||
### 1.4 D-4 — TLS 사용 시 호스트명 검증이 강제된다 (P1)
|
||||
|
||||
`make_client()`는 `tls_set(...)`만 호출하고 `tls_insecure_set()`을 부르지 않습니다. 실측(paho 2.1.0): `check_hostname=True`, `verify_mode=CERT_REQUIRED`, `_tls_insecure=False`, 우회 env **없음**.
|
||||
|
||||
| 시나리오 | 결과 |
|
||||
|---|---|
|
||||
| **A)** IP 호스트 + 정확한 CA 번들 핀 | `IP address mismatch, certificate is not valid for '127.0.0.1'` |
|
||||
| **B)** 사설 CA + `MQTT_CA_CERTS` 미설정 | `self signed certificate` |
|
||||
| **C)** `MQTT_TLS=0`으로 TLS 포트 접속 | **`CONNECTED (handshake ok)`** ← 소켓만 열림 |
|
||||
|
||||
→ (1) TLS 시 `MQTT_BROKER`는 **인증서 SAN의 DNS 이름** 필수(IP 금지). (2) 사설 CA면 `MQTT_CA_CERTS` 필수, Let's Encrypt면 **비워 둘 것**. (3) **소켓 연결 성공은 브로커 정상의 증거가 아님** — 검증은 CONNACK 또는 `/healthz`까지 도달해야 함.
|
||||
|
||||
### 1.5 D-5 — 환경변수 템플릿 양방향 드리프트 (P2)
|
||||
|
||||
`deploy/install.sh:521-522`가 `MQTT_RETRY_INTERVAL=2`, `MQTT_MAX_RETRIES=5`를 `.mam.env`에 **활성 기본값**으로 기록하지만 **읽는 코드 0건**. 실제 재시도는 `--attempts`(기본 3) + `with_retry(base_delay=0.5, factor=2.0, max_delay=8.0)`. 역으로 코드가 읽는 `MQTT_KEEPALIVE`(기본 60)는 `.mam.env.example`에 **0건**.
|
||||
|
||||
### 1.6 H-1 — freeze 경로의 조용한 공개 브로커 회귀 (P1)
|
||||
|
||||
`_load_dotenv()`는 `__file__`에서 위로 올라가다 `.agents` 또는 `.git`에서 멈춥니다. freeze 스냅샷 루트는 `.agents`만 담고 `.mam.env`는 없습니다(실측: `ls` 결과 `.agents` 단 하나).
|
||||
|
||||
| # | 조건 | 해석된 브로커 |
|
||||
|---|---|---|
|
||||
| 1 | 저장소 경로 스크립트, `MAM_ENV_FILE` 없음 | `nats.example.internal:8883 tls=True` ✅ |
|
||||
| 2 | **freeze 경로, `MAM_ENV_FILE` 없음** | **`broker.hivemq.com:1883 tls=False`** ❌ |
|
||||
| 3 | freeze 경로 + `MAM_ENV_FILE` | `nats.example.internal:8883 tls=True` ✅ |
|
||||
|
||||
정상 루프는 `run_loop.sh:106`의 `export MAM_ENV_FILE=...`으로 보호되나, **위임 브리프가 배포하는 명령줄은 freeze 경로를 직접 가리킵니다.**
|
||||
|
||||
### 1.7 H-2 — 잡 레코드가 브로커를 핀 고정 (P1)
|
||||
|
||||
`registry.py:73-74`가 등록 시점 브로커 블록을 스냅샷하고 `broker_config_from_job()`이 env보다 **우선** 적용합니다. → `.mam.env` 교체만으로는 기존 pending/running 잡이 전환되지 않습니다.
|
||||
|
||||
### 1.8 H-3 — 비밀번호 평문 보관 / 자동 토큰 발급 이득 (P2)
|
||||
|
||||
레코드에 `"password": "SUPERSECRET123"` 평문 확인(모드 0600, `.gitignore:14`에 `.mam/`). 동시에 **`tls` 또는 `username` 감지 시 `auth_token` 자동 발급** 확인 → 원격 인증 전환이 곧 HMAC 자동 활성화이며 `B-16`/`G-11` 위험을 대부분 부수 해소.
|
||||
|
||||
### 1.9 🆕 **N-1 — retained 메시지는 MQTT 구독자에게만 전달된다** (P1, C1 파생 신규 실측)
|
||||
|
||||
챌린저의 C1은 계정 경계만 다뤘으나, **계정 문제를 어떻게 풀든 바뀌지 않는 더 근본적인 경계**가 있습니다.
|
||||
|
||||
`server/mqtt.go` 실측 — retained 전달은 **MQTT SUBSCRIBE 처리 경로에서만** 호출됩니다:
|
||||
```go
|
||||
case mqttPacketSub: // ← MQTT SUBSCRIBE 패킷 처리
|
||||
...
|
||||
c.mqttEnqueueSubAck(pi, filters)
|
||||
c.mqttSendRetainedMsgsToNewSubs(subs) // ← 여기서만 호출
|
||||
|
||||
func (c *client) mqttSendRetainedMsgsToNewSubs(subs []*subscription) {
|
||||
for _, sub := range subs {
|
||||
if sub.mqtt != nil && sub.mqtt.prm != nil { ... } // ← MQTT 구독에만 존재하는 필드
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: NATS 네이티브 구독자와 WebSocket(NATS) 구독자는 **retained 메시지를 절대 받지 못합니다.** 계정을 합치든(Option B), export/import를 걸든(Option A) 이 사실은 변하지 않습니다.
|
||||
|
||||
**MAM에 주는 구체적 의미**:
|
||||
- `publish_event.py`는 `retain = args.retained or args.event in TERMINAL_EVENTS` — 즉 **종료 이벤트가 정확히 retained 대상**입니다.
|
||||
- 잡이 끝난 **뒤에** 접속한 NATS/WebSocket 대시보드는 **그 잡의 종료 이벤트를 보지 못합니다.** 라이브 스트리밍만 가능합니다.
|
||||
- `PRIVATE_SERVER.md` §5.2의 *"즉시 실시간 수신"* 주장은 **라이브 구간에 한정**해야 정확합니다.
|
||||
|
||||
**대시보드가 사후 상태까지 알아야 한다면 선택지는 2개뿐**:
|
||||
1. 대시보드를 **MQTT로** 붙인다(같은 `nats-server`의 1883 리스너 사용, retained 그대로 수신).
|
||||
2. §5.3의 **JetStream 리플레이 스트림을 옵트인**한다(`python.mqtt.jobs.>` 구독 스트림 + `max_age`/`max_bytes` 상한 필수).
|
||||
|
||||
이 두 갈래를 §5.2 개정안과 §A-1 주석에 명시합니다.
|
||||
|
||||
### 1.10 🆕 **H-4 — `MAM_ENV_FILE`이 없는 파일을 가리키면 모든 폴백이 무력화된다** (P1, C2 파생 신규 실측)
|
||||
|
||||
`_load_dotenv()` 도입부:
|
||||
```python
|
||||
explicit_file = os.environ.get("MAM_ENV_FILE")
|
||||
if explicit_file:
|
||||
if os.path.isfile(explicit_file):
|
||||
_parse_env_file(explicit_file)
|
||||
return # ← 파일이 없어도 여기서 종료. 다른 후보를 시도하지 않음
|
||||
```
|
||||
실측:
|
||||
|
||||
| 조건 | 해석된 브로커 |
|
||||
|---|---|
|
||||
| `MAM_ENV_FILE=<오타/이동된 경로>`, 저장소 경로 스크립트 | **`broker.hivemq.com tls=False`** ❌ |
|
||||
| `MAM_ENV_FILE=<정상 경로>` (대조군) | `nats.private.internal tls=True` ✅ |
|
||||
| freeze 경로 스크립트, cwd = 실제 저장소, env 없음 | **`broker.hivemq.com tls=False`** ❌ |
|
||||
|
||||
**중요**: 챌린저가 제안한 우선순위 재배열(`MAM_ENV_FILE → MAM_REAL_ROOT → …`)은 **이 경로를 고치지 못합니다.** 1순위에서 이미 `return`으로 탈출하기 때문입니다. 근본 결함은 순서가 아니라 **단일 후보 해석**입니다(§8 `B-17` 재정의).
|
||||
|
||||
세 번째 행은 챌린저의 `os.getcwd()` 도입 근거가 **실측으로 타당함**을 보여줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. §A — `PRIVATE_SERVER.md` 개정안: §9 「원격 서버 프로덕션 배포」 신설
|
||||
|
||||
### A-1. 프로덕션 `nats.conf` (**Rev.2 — 관측자 사용자 추가**)
|
||||
|
||||
```conf
|
||||
# nats.conf — 원격 프로덕션 (컨테이너 내부 절대경로 기준)
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. MAM 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # D-1: 절대경로. '~' 도, 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # D-3: 호스트 게시는 loopback 한정 (§A-3)
|
||||
|
||||
mqtt {
|
||||
port: 1883 # 공개 TLS 모델에서는 8883 + tls 블록 (§B-3)
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 기본 100 → 다중 에이전트 동시 발행 여유
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 (Rev.2: C1 반영) ────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. PRIVATE_SERVER.md §5.5 의 '동일 계정 배치' 처방
|
||||
{ user: mam_observer, password: $MAM_OBSERVER_PASS,
|
||||
permissions: {
|
||||
subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam.<fp>.jobs.>"
|
||||
publish: { deny: [">"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
# MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리)
|
||||
HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] }
|
||||
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
|
||||
}
|
||||
system_account: SYS
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **관측자는 반드시 `MAM` 계정 안에 둡니다.** NATS 계정은 하드 격리 경계이므로 `user: home`(계정 `HOME`)으로 접속한 클라이언트는 `python.mqtt.jobs.>`를 구독해도 **0건**을 받습니다. 이는 `PRIVATE_SERVER.md:230` §5.5가 이미 처방한 배치("MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치")와 정확히 일치합니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 **부록 X(Option A)** 를 따르십시오.
|
||||
|
||||
> [!WARNING]
|
||||
> **관측자가 받는 것과 받지 못하는 것 (§1.9 N-1)**
|
||||
> - ✅ 잡 실행 **중** 발생하는 모든 이벤트 (라이브 스트리밍)
|
||||
> - ❌ **이미 끝난 잡의 retained 종료 이벤트** — retained 는 **MQTT 구독자에게만** 전달됩니다. NATS/WebSocket 대시보드는 접속 이전 상태를 재구성하지 못합니다.
|
||||
> - 사후 상태가 필요하면 대시보드를 **MQTT(1883)로** 붙이거나 §5.3의 **JetStream 리플레이 스트림을 옵트인**하십시오.
|
||||
|
||||
> [!NOTE]
|
||||
> **계정별 JetStream 활성화는 필수**입니다. MQTT는 접속 계정 안에 `$MQTT_sess`·`$MQTT_rmsgs`·`$MQTT_out` 스트림을 만듭니다. 계정에 JetStream이 없으면 `JetStream not enabled for account`(ErrCode 10039, HTTP 503)로 실패합니다. 전역 요건도 별도로 존재합니다 — `mqtt requires JetStream to be enabled if running in standalone mode` (`mqtt.go:234`).
|
||||
> *다계정 구성에서의 계정별 요구는 스트림 생성 경로로부터의 추론이며, 위 처방은 fail-safe입니다. **M2b 스파이크 R-4에서 확인 항목으로 지정**합니다.*
|
||||
|
||||
### A-2. 프로덕션 `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # D-2: latest(=scratch)는 healthcheck 불가
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
environment:
|
||||
# 미해결 $VAR 는 파싱 에러 → 시크릿 누락 시 '기동 실패'로 fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set in .env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set in .env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set in .env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set in .env}
|
||||
ports:
|
||||
- "${MQTT_BIND:-127.0.0.1}:1883:1883"
|
||||
- "127.0.0.1:8222:8222" # D-3: 무인증 모니터링은 loopback 한정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080"
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Docker의 published 포트는 UFW를 우회합니다.** Docker가 삽입하는 NAT/FORWARD 규칙이 `ufw`의 INPUT 체인보다 먼저 평가되므로, `ufw deny 1883`을 걸어도 `-p 1883:1883`으로 게시한 포트는 인터넷에 열립니다. 본 계획이 방화벽 대신 **published 포트 자체에 바인드 주소를 명시**하는 이유입니다.
|
||||
|
||||
### A-3. 포트 노출 매트릭스
|
||||
|
||||
| 포트 | 용도 | Tailscale 모델 (권장) | 공개 TLS 모델 | 절대 금지 |
|
||||
|---|---|---|---|---|
|
||||
| 1883 | MQTT 평문 | tailnet IP 바인드 | ✖ 미게시 | 0.0.0.0 게시 |
|
||||
| 8883 | MQTT TLS | (불필요) | `0.0.0.0` + LE 인증서 | 인증 없이 게시 |
|
||||
| 4222 | NATS 네이티브 | tailnet IP 바인드 | 미게시(또는 TLS+인증) | 0.0.0.0 평문 |
|
||||
| 8222 | HTTP 모니터링 | **`127.0.0.1` 한정** | **`127.0.0.1` 한정** | 어떤 경우에도 공개 |
|
||||
| 8080 | WebSocket(`no_tls`) | tailnet IP 바인드 | 미게시 | 공개 인터페이스 게시 |
|
||||
|
||||
### A-4. 🆕 `PRIVATE_SERVER.md` §5.2 개정 (N-1 반영)
|
||||
|
||||
기존 §5.2 문장 *"웹 브라우저나 타 프로젝트의 NATS 구독자는 … 즉시 실시간 수신할 수 있습니다"* 에 다음 경계를 병기합니다.
|
||||
|
||||
```markdown
|
||||
- **경계 (필수 인지)**: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. MQTT의
|
||||
retained 메시지는 MQTT 구독자에게만 전달되므로(`mqttSendRetainedMsgsToNewSubs`가
|
||||
MQTT SUBSCRIBE 경로 전용), 잡이 끝난 뒤 접속한 NATS/WebSocket 대시보드는 그 잡의
|
||||
**종료 이벤트를 수신하지 못합니다**. 사후 상태가 필요하면 (a) 대시보드를 MQTT(1883)로
|
||||
연결하거나 (b) §5.3 JetStream 리플레이 스트림을 옵트인하십시오.
|
||||
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자
|
||||
(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject 가 보이지 않습니다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. §B — 원격 네트워킹 & 보안 가이드
|
||||
|
||||
### B-1. 노출 모델 3안 비교 및 권고
|
||||
|
||||
| 항목 | 🏆 **모델 T: Tailscale/WireGuard 오버레이** | 모델 P: 공개 TLS (Let's Encrypt) | 모델 S: SSH 터널 |
|
||||
|---|---|---|---|
|
||||
| 인터넷 노출 면적 | **0** | 8883 1개 | 0 |
|
||||
| 인증서 필요 | 불필요 (`MQTT_TLS=0`) | 필수 + 90일 갱신 | 불필요 |
|
||||
| **D-4 호스트명 제약** | **해당 없음** | 도메인 필수, IP 불가 | 해당 없음 |
|
||||
| 도메인 필요 | 불필요 | **필수** | 불필요 |
|
||||
| 이동성 | 자동 | 자동 | 터널 수동 관리 |
|
||||
| 장애 지점 | tailnet 코디네이터 | certbot 갱신 실패 | SSH 세션 |
|
||||
|
||||
**권고: 모델 T.** 근거 — (1) D-4의 도메인·SAN 제약을 소거, (2) D-3의 모니터링/WS 노출을 구조적으로 제거, (3) 롤백이 `.mam.env` 한 줄, (4) MAM은 **관측 사이드카**이므로(제어 평면은 `wait_for_job` 파일 폴링) 오버레이 지연이 오케스트레이션 정확성에 영향을 주지 않음.
|
||||
|
||||
### B-2. 방화벽 (UFW) — 2차 방어선
|
||||
|
||||
```bash
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# 모델 T: tailnet 인터페이스만 허용
|
||||
sudo ufw allow in on tailscale0 to any port 1883 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 4222 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 8080 proto tcp
|
||||
|
||||
# 모델 P: 8883만 공개
|
||||
# sudo ufw allow 8883/tcp
|
||||
# sudo ufw allow 80/tcp # certbot HTTP-01 챌린지 기간 한정
|
||||
|
||||
sudo ufw enable && sudo ufw status verbose
|
||||
```
|
||||
**Docker 우회 대응(필수)** — compose 옆 `.env`에 바인드 주소를 주입하고 실제 바인딩을 단언합니다:
|
||||
```bash
|
||||
MQTT_BIND=100.x.y.z # tailscale ip -4
|
||||
WS_BIND=100.x.y.z
|
||||
```
|
||||
```bash
|
||||
sudo ss -lntp | grep -E ':(1883|4222|8222|8080)\b'
|
||||
# 기대: 8222 는 127.0.0.1 에만, 1883/8080 은 tailnet IP 에만
|
||||
```
|
||||
|
||||
### B-3. 모델 P 전용 — TLS / Certbot
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d mam-broker.example.com
|
||||
|
||||
# nats.conf 의 mqtt 블록:
|
||||
# mqtt {
|
||||
# port: 8883
|
||||
# tls {
|
||||
# cert_file: "/etc/letsencrypt/live/mam-broker.example.com/fullchain.pem"
|
||||
# key_file: "/etc/letsencrypt/live/mam-broker.example.com/privkey.pem"
|
||||
# }
|
||||
# }
|
||||
# compose 볼륨: live/ 는 archive/ 로의 심볼릭 링크 → /etc/letsencrypt 전체를 마운트
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
|
||||
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nats.sh >/dev/null <<'SH'
|
||||
#!/bin/sh
|
||||
docker compose -f /srv/mam-nats/docker-compose.yml kill -s HUP nats
|
||||
SH
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nats.sh
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
**클라이언트 규칙 (D-4)**: `MQTT_BROKER`는 **도메인**(IP 금지). Let's Encrypt 사용 시 `MQTT_CA_CERTS`는 **설정하지 않음**. 사설 CA일 때만 지정하고 SAN에 접속명을 반드시 포함.
|
||||
|
||||
### B-4. 사용자 인증 및 시크릿 주입
|
||||
|
||||
```bash
|
||||
openssl rand -base64 32 # 계정/사용자별로 각각 생성 (mam_agent, mam_observer, home, sys)
|
||||
chmod 600 .env
|
||||
```
|
||||
NATS 파서는 미해결 `$VAR`를 **에러**로 처리하므로 시크릿이 비면 서버가 조용히 익명으로 뜨지 않고 **기동에 실패**합니다(fail-closed, 의도적 채택).
|
||||
|
||||
**H-3 완화 규칙**: (1) `MQTT_PASSWORD`는 사람이 재사용하는 암호가 아니라 **기계 생성 토큰**만 사용(레코드에 평문으로 남음). (2) 회전 시 서버 `.env` → `docker compose up -d` → 클라이언트 `.mam.env` → **잔여 잡 레코드 정리**(§C-3과 동일 절차). (3) 장기 과제는 `B-18`.
|
||||
|
||||
**권한 격리**: `mam_agent`는 발행/구독, `mam_observer`는 `publish: { deny: [">"] }`로 **발행 전면 금지**. 이는 A-2(외부 악의적 이벤트 주입) 대응과 같은 방향이며, HMAC(`auth_token`)과 이중 방어를 이룹니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. §C — 클라이언트 설정 및 원격 검증 플레이북
|
||||
|
||||
### C-1. `.mam.env` (모델별)
|
||||
|
||||
```bash
|
||||
# ── 모델 T (Tailscale, 권장) ──────────────────────────────────
|
||||
MQTT_BROKER="mam-hub.tailXXXX.ts.net" # 또는 100.x.y.z (평문이므로 IP 가능)
|
||||
MQTT_PORT=1883
|
||||
MQTT_TLS=0
|
||||
MQTT_USERNAME=mam_agent
|
||||
MQTT_PASSWORD=<기계 생성 토큰>
|
||||
MQTT_KEEPALIVE=60 # D-5: 코드가 실제로 읽는 값. 템플릿에 추가 필요
|
||||
|
||||
# ── 모델 P (공개 TLS) ────────────────────────────────────────
|
||||
# MQTT_BROKER="mam-broker.example.com" # D-4: 인증서 SAN 의 DNS 이름. IP 금지
|
||||
# MQTT_PORT=8883
|
||||
# MQTT_TLS=1
|
||||
# MQTT_CA_CERTS 는 Let's Encrypt 사용 시 '설정하지 않음'
|
||||
```
|
||||
> **D-5 교정**: `MQTT_RETRY_INTERVAL` / `MQTT_MAX_RETRIES`는 어떤 코드도 읽지 않습니다. 템플릿에서 제거하거나 "미사용(historical)"로 강등하고, 재시도 조정은 `publish_event.py --attempts`임을 명시. 역으로 `MQTT_KEEPALIVE`는 추가.
|
||||
|
||||
### C-2. 원격 검증 플레이북 (R-1 ~ **R-10**)
|
||||
|
||||
| ID | 검증 항목 | 방법 | 통과 기준 |
|
||||
|---|---|---|---|
|
||||
| **R-1** | 브로커 헬스 | SSH 터널 후 `curl -sf http://127.0.0.1:8222/healthz` | **HTTP 200** |
|
||||
| **R-2** | 리스너 + TLS 신원 | `curl -s .../varz \| grep -i mqtt`; 모델 P는 `openssl s_client -connect H:8883 -servername H` | MQTT 리스너 노출, SAN에 `MQTT_BROKER` 포함 |
|
||||
| **R-3** | 노출 면적 단언 | 외부 망에서 `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 전부 closed/filtered |
|
||||
| **R-4** | 왕복 pub/sub + 계정 JetStream | 임시 잡 등록 → 구독자 기동 → `progress` 발행 → 수신 → 종결 | 수신 성공, `JetStream not enabled for account` **미발생** |
|
||||
| **R-5** | **retained 종료 이벤트 (MQTT)** | `--event completed` 발행 **후** 신규 `job_subscriber.py` 기동 | 즉시 최종 이벤트 수신 |
|
||||
| **R-6** | 브로커 신원 단언 | `grep -c 'broker.hivemq.com' .mam/delegate_job_logs/$JID/events.ndjson` | **0**, 원격 호스트 등장 |
|
||||
| **R-7** | freeze 회귀 (H-1/H-4) | freeze 경로 `publish_event.py`를 `MAM_ENV_FILE` 없이 실행 / 그리고 오타 경로로 실행 | 공개 브로커로 나가지 않음 — **현재는 양쪽 다 실패가 기대값**이며 `B-17`의 근거 |
|
||||
| **R-8** | 전체 회귀 스위트 | `.venv/bin/python -m pytest tests/ -q` | 현재 기준 **290 passed** |
|
||||
| 🆕 **R-9** | **관측자 계정 경계** | `mam_observer`로 NATS 구독 → 이벤트 수신 확인. 이어서 `home`(계정 HOME)으로 동일 구독 | `mam_observer` **수신**, `home` **0건** (= 격리 정상) |
|
||||
| 🆕 **R-10** | **N-1 retained 경계 확인** | 잡 종료 **후** NATS 네이티브 구독자를 새로 붙임 | **0건 수신**이 정상. 수신되면 N-1 전제가 틀린 것이므로 §A-4 문구 재작성 |
|
||||
|
||||
> R-9/R-10은 챌린지 C1이 제기한 계정 문제와, 그보다 근본적인 retained 경계를 **각각 실증**합니다. 특히 R-10은 **반증 가능한 형태**로 설계되어 있습니다.
|
||||
|
||||
**R-4 구체 절차**:
|
||||
```bash
|
||||
JID=$(.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
|
||||
--registry-dir .mam/jobs \
|
||||
register --prompt "remote broker connectivity test" --agent-session "herdr:test")
|
||||
|
||||
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
|
||||
--registry-dir .mam/jobs --job "$JID" --event progress --detail "remote broker verified" -v
|
||||
|
||||
.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
|
||||
--registry-dir .mam/jobs status --job "$JID" --set completed # 유령 잡 방지
|
||||
```
|
||||
> `--registry-dir`는 **부모 파서 인자**이므로 서브커맨드 앞에 옵니다. `register`에는 `--job-id`가 없어 ID는 stdout에서 캡처합니다(실측 확인).
|
||||
|
||||
**지연 측정**:
|
||||
```bash
|
||||
.venv/bin/python - <<'PY'
|
||||
import sys, time, statistics
|
||||
sys.path.insert(0, '.agents/skills/multi-agent-mux-delegate-job/scripts')
|
||||
import mqtt_common as m
|
||||
cfg = m.broker_config_from_env()
|
||||
print(f"target: {cfg.host}:{cfg.port} tls={cfg.tls}")
|
||||
conn, rtt = [], []
|
||||
for _ in range(5):
|
||||
c = m.make_client("latency", cfg)
|
||||
t0 = time.perf_counter(); c.connect(cfg.host, cfg.port, 10); c.loop_start()
|
||||
conn.append((time.perf_counter() - t0) * 1000)
|
||||
t1 = time.perf_counter()
|
||||
info = c.publish("mam/latency/probe", b"x", qos=1); info.wait_for_publish(10)
|
||||
rtt.append((time.perf_counter() - t1) * 1000)
|
||||
c.loop_stop(); c.disconnect()
|
||||
print(f"connect p50={statistics.median(conn):.1f}ms max={max(conn):.1f}ms")
|
||||
print(f"qos1 rtt p50={statistics.median(rtt):.1f}ms max={max(rtt):.1f}ms")
|
||||
PY
|
||||
```
|
||||
**판정**: QoS1 RTT p50 > 200ms면 `mqtt.ack_wait` 상향, > 1s면 오버레이 경로(릴레이 폴백) 점검. `with_retry` 백오프가 0.5s→1s→2s이므로 수백 ms RTT에서는 재시도 없이 통과해야 정상입니다.
|
||||
|
||||
### C-3. 전환(Cutover) 절차 — H-2 대응
|
||||
|
||||
```
|
||||
[1 드레인] ──> [2 잔여 스캔] ──> [3 .mam.env 교체] ──> [4 R-1~R-10] ──> [5 레거시 차단]
|
||||
```
|
||||
1. **드레인**: 신규 위임 중단, 진행 중 잡이 모두 terminal 될 때까지 대기.
|
||||
2. **잔여 스캔** — 옛 브로커에 핀 고정된 레코드 확인:
|
||||
```bash
|
||||
.venv/bin/python - <<'PY'
|
||||
import json, glob
|
||||
for p in sorted(glob.glob('.mam/jobs/*.json')):
|
||||
d = json.load(open(p))
|
||||
if d.get('status') not in ('completed', 'error', 'cancelled'):
|
||||
b = d.get('broker') or {}
|
||||
print(f"{d.get('job_id')} status={d.get('status'):<9} broker={b.get('host')}:{b.get('port')}")
|
||||
PY
|
||||
```
|
||||
출력이 비어야 3단계 진입. 남으면 종결 처리하거나 `broker` 블록을 마이그레이션.
|
||||
3. `.mam.env` 교체 — **코드 변경 0줄.**
|
||||
4. R-1 ~ R-10 전건 통과.
|
||||
5. 레거시 차단: 공개 브로커 주소가 활성 기본값으로 남지 않도록 `.mam.env` / `deploy/install.sh` 점검.
|
||||
|
||||
---
|
||||
|
||||
## 5. §D — `implementation_plan.md` 개정안
|
||||
|
||||
### D-a. M2 분할
|
||||
|
||||
| 마일스톤 | 이름 | DoD | 게이트 |
|
||||
|---|---|---|---|
|
||||
| M0 | 문서 정합성 | (완료) | (통과, 290 실측) |
|
||||
| M1 | Track 0 내결함성 | (완료 — `c6b6c77`) | (통과) |
|
||||
| **M2a** | 로컬 스파이크 (Track 1) | 격리 클론에서 S-1 ~ S-9 완수 | S-3 retained 통과 |
|
||||
| **M2b** | 원격 프로덕션 전환 (Track 1R) | D-1~D-5 교정 + §A/§B 배포 + §C-3 전환 | **R-3 · R-5 · R-6 · R-9 동시 통과**, R-7·R-10 결과 기록 |
|
||||
| M3 | Track 2 보안 | A-2 지문 토픽, G-11 | 지문 토픽 확인 후 레거시 구독 제거 **+ 관측자 권한/Export subject 동시 갱신** |
|
||||
| M4 | Track 3 동기화 | 문서/배포 정합 | 전체 스위트 Green |
|
||||
|
||||
> **M2b 진입 선행 조건**: D-1 ~ D-5 교정이 `PRIVATE_SERVER.md`에 반영되고 신규 가드가 통과해야 합니다. 교정 전 배포는 D-1(스토리지 유실)·D-2(healthcheck 불가)·D-3(모니터링 공개)·D-4(TLS 접속 불가)로 **반드시 실패**합니다.
|
||||
|
||||
> 🆕 **M3에 추가된 결합 항목**: 토픽 루트가 `python/mqtt/jobs/…` → `mam/<fp>/jobs/…`로 바뀌면 §A-1의 `mam_observer.subscribe.allow`와 (Option A 채택 시) export subject가 **조용히 매칭 실패**합니다. 가드 `G-D9`가 이를 기계적으로 강제합니다.
|
||||
|
||||
### D-b. Track 1R (신설)
|
||||
|
||||
| 트랙 | 대상 | 목표 | 변경 지점 |
|
||||
|---|---|---|---|
|
||||
| **Track 1R** | 원격 배포 | VPS/홈랩 `nats-server` 상시 가동 및 MAM 전환 | `PRIVATE_SERVER.md` §9/§5.2, 서버측 `nats.conf`·`docker-compose.yml`, `.mam.env` |
|
||||
|
||||
### D-c. M2b 단계별 로드맵
|
||||
|
||||
```
|
||||
[P0 교정] D-1~D-5 문서 교정 + §5.2 경계 명문화 + 신규 가드 7종
|
||||
▼
|
||||
[P1 서버 준비] VPS/홈랩 프로비저닝, Docker, Tailscale 가입
|
||||
▼
|
||||
[P2 배포] nats.conf + compose 기동, healthcheck healthy 확인
|
||||
▼
|
||||
[P3 잠금] 바인드 주소 한정 + UFW + ss/nmap 로 노출 면적 0 단언 (R-3)
|
||||
▼
|
||||
[P4 전환] 드레인 → 잔여 스캔 → .mam.env 교체 (§C-3)
|
||||
▼
|
||||
[P5 검증] R-1 ~ R-10. R-5(retained) / R-9(계정 경계) 를 최종 관문으로
|
||||
▼
|
||||
[P6 상시화] 로그 로테이션, JetStream 볼륨 백업, healthcheck 알림
|
||||
```
|
||||
**P6 최소 요건**: `nats-data` 볼륨 주기 스냅샷(**retained 종료 이벤트가 여기 있음 — 볼륨 유실 = 이벤트 유실**), healthcheck 상태 감시, JetStream 리플레이 스트림 사용 시 `max_age`/`max_bytes` 상한 필수.
|
||||
|
||||
### D-d. 체크리스트
|
||||
|
||||
```markdown
|
||||
### M2b: 원격 프로덕션 전환 (Track 1R)
|
||||
- [ ] D-1 store_dir 절대경로 교정 + 비인용 heredoc (PRIVATE_SERVER.md:73, :146)
|
||||
- [ ] D-2 이미지 핀 nats:2.12-alpine + /healthz healthcheck
|
||||
- [ ] D-3 8222/8080 바인드 주소 한정
|
||||
- [ ] D-4 TLS 절의 IP 예시 제거 및 DNS/SAN 요건 명시
|
||||
- [ ] D-5 .mam.env.example 정합 (MQTT_KEEPALIVE 추가 / RETRY·MAX_RETRIES 강등)
|
||||
- [ ] N-1 §5.2 에 retained=MQTT 전용 경계 명문화 + §A-1 mam_observer 추가
|
||||
- [ ] 신규 가드 G-D5(강화) ~ G-D9, G-R1, G-R2 구현 및 mutation 확인 (290 → 297)
|
||||
- [ ] 서버 배포 및 R-3 노출 면적 0 단언
|
||||
- [ ] §C-3 드레인·잔여 스캔 후 .mam.env 전환
|
||||
- [ ] R-1 ~ R-10 전건 통과 (R-5 / R-9 최종 관문)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 회귀 가드 (7종) — `tests/test_deploy_freshness.py::test_d7` 계보
|
||||
|
||||
| ID | 가드 내용 | 변이 검출 기준 (Mutation) |
|
||||
|---|---|---|
|
||||
| **G-D5** 🔺강화 | **2항 검사**: (i) 펜스 블록 내 모든 `store_dir:` 값이 `/`로 시작하는 절대경로, (ii) 해당 값을 기록하는 heredoc 구분자가 **비인용**(`<<EOF`). `$HOME`은 비인용 heredoc 안에서만 허용 | `~/.local/...` 복원 시 FAIL **그리고** `<<'EOF'` + `"$HOME/..."` 조합 도입 시에도 FAIL |
|
||||
| **G-D6** | 문서 내 모든 `nats:` 이미지 참조가 `latest`가 아니고 `-alpine` 포함 | `nats:latest` 복원 시 FAIL |
|
||||
| **G-D7** | compose 예제의 `8222` 게시 항목이 `127.0.0.1:` 접두를 가짐 | `"8222:8222"` 복원 시 FAIL |
|
||||
| **G-D8** | `MQTT_TLS=1`이 등장하는 예제 블록 안의 `MQTT_BROKER` 값이 IP 리터럴이 아님 | `MQTT_BROKER="192.168.1.100"` + TLS 조합 복원 시 FAIL |
|
||||
| 🆕 **G-D9** | 문서 config 예제의 subject 리터럴(`mam_observer.subscribe.allow`, Option A의 export/import subject)이 `mqtt_common.DEFAULT_TOPIC_ROOT`를 점 표기로 변환한 값과 **접두 일치** | `DEFAULT_TOPIC_ROOT`를 `mam/<fp>/jobs`로 바꾸고 문서를 갱신하지 않으면 FAIL |
|
||||
| **G-R1** | `run_loop.sh`에 `export MAM_ENV_FILE=` 라인 존재 단언 + `.agents`만 있고 `.mam.env`가 없는 임시 루트에서의 회귀 동작을 명시적으로 고정 | `run_loop.sh:106` export 제거 시 FAIL |
|
||||
| **G-R2** | 코드가 읽는 모든 `MQTT_*` 이름이 `.mam.env.example`에 존재(특히 `MQTT_KEEPALIVE`)하고, 코드가 읽지 않는 이름은 활성 기본값으로 기록되지 않음 | `MQTT_KEEPALIVE` 제거 또는 `MQTT_RETRY_INTERVAL=2` 활성 복원 시 FAIL |
|
||||
|
||||
**구현 규칙(Rev.1 승계 + C3 반영 확인)**:
|
||||
- 문서 가드는 **펜스 코드 블록에 한정**해 스캔하고, 산문 errata(예: *"과거에는 `nats:latest`를 권장했으나…"*)가 오탐되지 않음을 **동반 단언**합니다. — *이 규정은 Rev.1 §6에 이미 있었으며 C3의 요청과 동일합니다. 변경 없이 재확인합니다.*
|
||||
- G-D9와 G-R2는 소스에서 값을 **정적으로 수집**해 문서와 대조합니다(브로커 접속 없음).
|
||||
|
||||
**테스트 수 전망**: 290 (현재 실측) → **297** (G-D5 강화 + G-D6~G-D9, G-R1, G-R2) → **298** (M3의 G-11).
|
||||
|
||||
---
|
||||
|
||||
## 7. 롤백 전략
|
||||
|
||||
| 실패 지점 | 롤백 | 비용 |
|
||||
|---|---|---|
|
||||
| R-5 retained 실패 | `.mam.env`의 `MQTT_BROKER`만 `eclipse-mosquitto`로 교체 | 코드 0줄, 즉시 |
|
||||
| R-9 계정 경계 실패 | `mam_observer` 권한 블록만 수정, MAM 본체 무영향 | 서버 설정 1곳 |
|
||||
| 원격 링크 불안정 | `.mam.env`를 직전 값으로 원복 | 코드 0줄 |
|
||||
| 서버 전소 | Track 0 덕분에 **루프는 계속 완주**(디스크 폴백). 관측만 일시 상실 | 0 |
|
||||
| JetStream 볼륨 유실 | retained 종료 이벤트 유실 → 백업 스냅샷 복구 | P6 백업 필요 |
|
||||
|
||||
Track 0(`B-14`/`B-15`)은 브로커 제품·위치와 무관한 순이득이므로 롤백하지 않습니다. **원격 전환 전체가 가역적인 이유가 M1 완료 덕분입니다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. 후속 코드 과제 (Rev.2 재정의)
|
||||
|
||||
### B-17 🔺재정의 — `_load_dotenv` 단일 후보 해석 (P1)
|
||||
|
||||
> **C2 판정**: 챌린저의 우선순위 목록은 방향이 옳으나, **근본 결함은 순서가 아니라 "루트를 하나만 정하고 끝낸다"는 구조**입니다(§1.10 H-4). 순서만 바꾸면 `MAM_ENV_FILE` 오타 경로에서 여전히 공개 브로커로 폴백합니다.
|
||||
|
||||
**처방 — 순서 있는 후보 목록 + 첫 적중 우선(first-hit-wins)**:
|
||||
```
|
||||
1) $MAM_ENV_FILE (파일이 실제로 존재할 때만 채택. 부재 시 return 하지 말고 계속 진행) ← H-4 교정
|
||||
2) $MAM_REAL_ROOT (run_loop.sh:104 가 export)
|
||||
3) $WORKSPACE_ROOT (run_loop.sh:105 가 export)
|
||||
4) walk_up(__file__) (저장소 직접 실행에서 이미 정상 동작함이 실측됨)
|
||||
5) walk_up(os.getcwd()) (freeze 경로를 수동 실행하는 경우를 구제 — 챌린저 근거가 실측으로 타당)
|
||||
→ 각 후보에서 .mam.env / .env 를 찾고, 첫 적중을 채택
|
||||
→ 전부 실패하면 반드시 경고 로그: "no env file found; falling back to public default broker"
|
||||
```
|
||||
**Rev.1 대비 / 챌린저 제안 대비 차이 3가지**:
|
||||
1. `MAM_ENV_FILE` 부재 시 **조기 탈출 제거**(H-4). 챌린저 제안으로는 고쳐지지 않는 경로입니다.
|
||||
2. `walk_up(__file__)`을 cwd보다 **앞**에 둡니다. 저장소 직접 실행은 이미 정확히 동작함이 실측되었고, cwd를 앞세우면 **다른 프로젝트의 `.mam.env`를 읽는 교차 오염** 위험만 커집니다(MAM은 다중 프로젝트 사용을 전제).
|
||||
3. cwd는 **`walk_up(os.getcwd())`** 로 둡니다. 저장소 하위 디렉터리에서 실행해도 동작해야 하기 때문입니다.
|
||||
4. **최종 폴백 경고는 순서와 무관한 안전망**이므로 필수 요건으로 유지합니다. 어떤 순서든 놓칠 수 있습니다.
|
||||
|
||||
### B-18 — 잡 레코드 자격증명 평문 보관 (P2)
|
||||
|
||||
`to_registry_block()`에서 `password`를 마스킹하거나 레코드 대신 실행 시점 env에서만 해석. `broker_config_from_job()`의 override 우선순위 계약(H-2와 동일 지점)과 함께 재검토.
|
||||
|
||||
---
|
||||
|
||||
## 9. 부록 X — Option A (계정 간 Export / Import): **예외 경로**
|
||||
|
||||
챌린저가 1순위로 제안한 패턴입니다. **기본 채택하지 않으며**, 관측자가 실제로 **다른 신뢰 도메인**에 속할 때만 사용합니다.
|
||||
|
||||
**문법 검증 완료** (소스 대조):
|
||||
```conf
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled
|
||||
users: [ { user: mam_agent, password: $MAM_BROKER_PASS } ]
|
||||
exports: [ { stream: "python.mqtt.jobs.>", accounts: [HOME] } ] # accounts 로 수입자 제한 권장
|
||||
}
|
||||
HOME: {
|
||||
users: [ { user: home, password: $HOME_BROKER_PASS } ]
|
||||
imports: [ { stream: { account: MAM, subject: "python.mqtt.jobs.>" } } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
- `exports: [ { stream: "..." } ]` / `imports: [ { stream: { account: X, subject: "..." } } ]` 문법은 공식 문서와 일치합니다.
|
||||
- **`>` 와일드카드는 유효**합니다 — export subject는 `IsValidSubject`로 검증되며(`opts.go:3517`) 이 함수는 마지막 토큰의 `>`를 허용합니다(와일드카드 금지용 `IsValidLiteralSubject`는 별도 함수이며 export에 쓰이지 않습니다).
|
||||
- 수입 측은 `prefix:` / `to:`가 없으면 **동일 subject**로 구독합니다.
|
||||
|
||||
**기본 채택하지 않는 근거 3가지**:
|
||||
1. **A-2 목적과 상충** — 범용 홈랩 계정에 **모든 잡의 페이로드**(`detail` 본문 포함)를 열어 줍니다. 워크스페이스 격리를 강화하려는 Track 2와 반대 방향입니다.
|
||||
2. **M3 조용한 파손** — export subject가 토픽 루트를 **두 번째로 하드코딩**하는 지점이 됩니다. 지문 토픽 전환 시 매칭이 조용히 끊깁니다(→ `G-D9`가 강제).
|
||||
3. **N-1을 해결하지 못함** — import는 라이브 스트림이며 **retained를 옮기지 않습니다.** 즉 Option A를 써도 사후 접속 대시보드는 종료 이벤트를 보지 못합니다. Option B와 동일한 한계입니다.
|
||||
|
||||
→ 결론: 단일 사용자 홈랩에서는 **Option B(§A-1의 `mam_observer`)** 가 저장소 §5.5 처방과 일치하고 노출도 최소입니다. Option A는 "다른 사람/다른 신뢰 도메인이 관측한다"는 요구가 실제로 생겼을 때 도입합니다.
|
||||
|
||||
---
|
||||
|
||||
## 10. 미결 질문 (사용자/Creator 판단 사항)
|
||||
|
||||
1. **노출 모델**: 모델 T(Tailscale) 권고. 보유 도메인이 있고 외부 협업자가 붙는다면 모델 P + §B-3.
|
||||
2. **관측 클라이언트의 프로토콜**: N-1 때문에 **사후 상태가 필요하면 MQTT로 붙는 것이 정답**입니다. WebSocket/NATS 대시보드를 고집한다면 §5.3 JetStream 리플레이 스트림 옵트인이 필요하며, 이는 추가 디스크 관리 부담을 동반합니다. 어느 쪽을 택할지 결정이 필요합니다.
|
||||
3. **서버측 자산의 위치**: `nats.conf`/`docker-compose.yml`을 `deploy/nats/`로 커밋할지, 서버 로컬에만 둘지. 커밋하면 G-D5~G-D7·G-D9를 **실제 파일에 직접** 걸 수 있어 문서 가드보다 강해집니다. 시크릿은 어느 쪽이든 `.env`로 분리.
|
||||
4. **`B-17` 우선순위**: H-1·H-4는 원격 전환의 보안 목적을 직접 훼손합니다. Planner 권고는 **M2b 진입 전 처리**입니다.
|
||||
5. **`implementation_plan.md` 파일명**: 저장소의 대문자 관례와 어긋납니다(이전 리비전에서도 제기).
|
||||
|
||||
---
|
||||
|
||||
### 부록 Y — Rev.2에서 새로 수행한 실측
|
||||
|
||||
| 대상 | 확인 결과 |
|
||||
|---|---|
|
||||
| retained 전달 경로 | `mqttSendRetainedMsgsToNewSubs`는 `mqttPacketSub` 처리에서만 호출, `sub.mqtt.prm` 순회 → **MQTT 구독자 전용** |
|
||||
| NATS 변수 해석 범위 | `isVariable()`은 **비인용 문자열**에서만 도달. `lexQuotedString`은 *"will not interpret any internal contents"* |
|
||||
| export subject 와일드카드 | `IsValidSubject`가 마지막 토큰 `>`를 허용(`isValidSubject`의 `fwc` 분기). export는 `IsValidLiteralSubject`를 쓰지 않음 |
|
||||
| export/import 문법 | `exports: [{stream: "..."}]`, `imports: [{stream: {account: X, subject: "..."}}]`, `prefix`/`to` 지원 |
|
||||
| 계정 격리 | *"Accounts create isolated tenant subject spaces"*, `jetstream: enabled`는 계정 단위 |
|
||||
| `MAM_ENV_FILE` 오타 경로 | **`broker.hivemq.com tls=False`** (다른 후보 미시도) |
|
||||
| 대조군(정상 경로) | `nats.private.internal tls=True` |
|
||||
| freeze 스크립트 + cwd=저장소 | **`broker.hivemq.com tls=False`** (챌린저의 cwd 도입 근거 성립) |
|
||||
| `PRIVATE_SERVER.md:230` | *"MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치"* — Option B는 기존 처방 |
|
||||
@@ -0,0 +1,161 @@
|
||||
# Cross-Code Review Report - Job e1c4e9c3
|
||||
|
||||
**Review Target**: Remote Docker deployment plan for `nats-server` (Track 1R)
|
||||
**Scope**: `PRIVATE_SERVER.md`, `implementation_plan.md`, `.agents/skills/lib.sh`, `.mam.env.example`, `tests/test_deploy_freshness.py`
|
||||
**Reviewer**: cline (herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||
**Date**: 2026-08-22
|
||||
**Commit base**: c6b6c77 (HEAD)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
The changeset establishes a comprehensive remote `nats-server` Docker production deployment plan (Track 1R / M2b) across 5 files (+413 / -66 lines). It delivers all four task deliverables: production Docker Compose & nats.conf, networking/security guide, client config & verification playbooks, and a phased rollout roadmap. Seven new regression guards (D-15~D-21) lock the documentation invariants.
|
||||
|
||||
**Test results**: 297 tests collected (290 -> 297); deploy_freshness 20/20 pass; tier1+o2 67/67 pass; sanity 2/2 pass. No regressions detected in the fast subset.
|
||||
|
||||
**Verdict**: PASS. One Medium documentation inconsistency (section 9.2 production compose omits NATS 4222 while section 9.3 UFW and R-3 reference it) and several Low/Very Low findings - none require re-planning.
|
||||
|
||||
---
|
||||
|
||||
## 2. Changed Files Overview
|
||||
|
||||
| File | Delta | Purpose |
|
||||
|---|---|---|
|
||||
| `.agents/skills/lib.sh` | +8 / -5 | Claude startup dialog handling robustness in `wait_for_tui_ready` / `handle_startup_dialogs` |
|
||||
| `.mam.env.example` | +4 | Document `MQTT_KEEPALIVE` env var |
|
||||
| `PRIVATE_SERVER.md` | +239 / -36 | D-1~D-5 corrections, N-1 boundary, section 9 remote production guide, Appendix X |
|
||||
| `implementation_plan.md` | +44 / -20 | Split M2 -> M2a/M2b, add Track 1R roadmap section 5, renumber sections |
|
||||
| `tests/test_deploy_freshness.py` | +112 / -11 | D-11 cleanup (remove dead `recognized` set, add `MQTT_BIND`); add D-15~D-21 guards |
|
||||
|
||||
---
|
||||
|
||||
## 3. Detailed Review by File
|
||||
|
||||
### 3.1 `.agents/skills/lib.sh`
|
||||
|
||||
**Changes**:
|
||||
1. `wait_for_tui_ready()` (line 1519): adds `handle_startup_dialogs "$sess" 1 || true` inside the 30-iteration loop for `$agent = "claude"` only.
|
||||
2. `handle_startup_dialogs()` (line 1725): broadens trust-dialog regex to `'Do you trust the files|Yes, I trust this folder|Quick safety check'`.
|
||||
3. (lines 1733-1735): adds a new `'Press Enter to continue'` branch; adds `${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}` fallback default.
|
||||
4. (lines 1738-1739): reduces sleep from 2s->1s and `waited` increment from 2->1.
|
||||
|
||||
**Verification**:
|
||||
- `bash -n` syntax check: PASS
|
||||
- `_MAM_READY_TOKENS_CLAUDE` is defined at line 63 -> the `:-` fallback is defensive but harmless (consistent with prior N5 observation).
|
||||
- The regex broadening correctly handles newer Claude dialog variants ("Quick safety check" appeared in recent Claude Code versions).
|
||||
|
||||
**Findings**:
|
||||
|
||||
**L-1 (Low) - Latency overhead in `wait_for_tui_ready`**: The new `handle_startup_dialogs "$sess" 1` call adds ~1s (one loop iteration with `sleep 1`) per `wait_for_tui_ready` iteration even when no dialog is present. Combined with the existing `sleep 1`, each of the 30 iterations now takes ~2s (max ~60s vs previous ~30s). Acceptable for TUI readiness but doubles worst-case latency. Not a blocker - the function returns early when ready tokens appear.
|
||||
|
||||
**L-2 (Low) - `handle_startup_dialogs` default timeout halved**: Changing `sleep 2; waited+=2` -> `sleep 1; waited+=1` halves the default timeout from ~40s to ~20s. When called with the default `timeout=20`, the function now runs at most ~20s instead of ~40s. This is reasonable for Claude dialogs (which appear within seconds) but reduces the safety margin for slow environments. The `wait_for_tui_ready` call uses `timeout=1` (1s), so it is unaffected by this change.
|
||||
|
||||
### 3.2 `.mam.env.example`
|
||||
|
||||
Adds `MQTT_KEEPALIVE=60` with a descriptive comment. Verified `mqtt_common.py:234` reads it via `_env_int("MQTT_KEEPALIVE", 60)` and the dataclass default is `keepalive: int = 60` (line 179). Consistent. PASS
|
||||
|
||||
### 3.3 `PRIVATE_SERVER.md`
|
||||
|
||||
**D-1 store_dir correction**: Changed from literal `"~/.local/share/nats/data"` (which does not expand in nats.conf) to `"/data"` (Docker) and `"$HOME/..."` (native, via unquoted `<<EOF` heredoc). Verified by D-15 guard.
|
||||
|
||||
**D-2 image pin**: `nats:latest` -> `nats:2.12-alpine`. The comment correctly notes `latest` is scratch-based (no `wget` for healthcheck). Alpine includes busybox `wget`. Verified by D-16 guard.
|
||||
|
||||
**D-3 port binding**: All ports now bind to `127.0.0.1` or `${*_BIND:-127.0.0.1}`. Port 8222 (unauthenticated monitoring) is hardcoded to `127.0.0.1`. Verified by D-17 guard.
|
||||
|
||||
**D-4 TLS examples**: TLS blocks use DNS domain names (`mam-broker.example.com`), not IP literals. Verified by D-18 guard.
|
||||
|
||||
**N-1 retained boundary**: Section 5.2 now explicitly documents that NATS/WebSocket subscribers joining after job termination will not receive retained MQTT terminal events, with two remediation paths (MQTT reconnect or JetStream opt-in).
|
||||
|
||||
**Section 9 Remote production guide**: Well-structured with:
|
||||
- 9.1: Production nats.conf with multi-tenant accounts, `mam_observer` read-only user, JetStream, `ack_wait: 60s` for WAN, `max_ack_pending: 1024`.
|
||||
- 9.2: Production compose with fail-closed env (`${VAR:?set in .env}`), healthcheck, log rotation.
|
||||
- 9.3: Tailscale vs TLS comparison table, UFW rules, secret generation.
|
||||
- 9.4: R-1~R-10 verification playbook + WAN latency probe.
|
||||
- 9.5: 5-step cutover procedure.
|
||||
- Appendix X: Account export/import for cross-trust-domain scenarios.
|
||||
|
||||
**Findings**:
|
||||
|
||||
**M-1 (Medium) - Section 9.2 production compose omits NATS 4222 port**: The section 9.2 `docker-compose.yml` (lines 405-408) publishes only ports 1883, 8222, 8080 - **missing `${NATS_BIND:-127.0.0.1}:4222:4222`**. This contradicts:
|
||||
- The task brief which explicitly requires "NATS 4222" in the production compose.
|
||||
- Section 9.3 UFW rule `sudo ufw allow in on tailscale0 to any port 4222 proto tcp` (line 447) - a dead rule since the container does not publish 4222 to the host.
|
||||
- R-3 verification playbook (line 473) which nmap-tests 4222.
|
||||
|
||||
The section 4.1 *dev* compose (lines 101, 122) correctly includes 4222. The section 9.1 nats.conf enables NATS default port 4222 inside the container (nats-server listens on 4222 by default), but without the compose port mapping it is unreachable from the tailnet. For MAM-only deployments (MQTT 1883 only), 4222 is optional - but the UFW rule and R-3 test should then be updated to match, or the port should be added to section 9.2. **Fix**: Add `- "${NATS_BIND:-127.0.0.1}:4222:4222"` to section 9.2 ports, OR remove 4222 from section 9.3 UFW and R-3.
|
||||
|
||||
**V-1 (Very Low) - Misleading `store_dir` comment (line 73)**: `store_dir: "/data"` is annotated `# Docker ... (native execution $HOME expansion)` - but `/data` is a fixed absolute path that does NOT expand to `$HOME`. Native execution uses a separate config block (line 145, `"$HOME/.local/share/nats/data"`). The parenthetical comment is slightly misleading; a reader might expect `/data` to auto-expand. Cosmetic only.
|
||||
|
||||
### 3.4 `implementation_plan.md`
|
||||
|
||||
Splits M2 -> M2a (local spike) + M2b (remote production), adds Track 1R roadmap (new section 5), renumbers sections 5->6, 6->7, and removes the old section 7 dependency graph (content folded into the milestone flow diagram at line 33). The M2b gate condition correctly cites R-3/R-5/R-6/R-9 as the final gates.
|
||||
|
||||
**Findings**:
|
||||
|
||||
**V-2 (Very Low) - Unchecked guard implementation checkbox**: The M2b checklist item `- [ ] new guards G-D5 ~ G-D9, G-R1, G-R2 implementation and verification (290 -> 297)` is marked `[ ]` (incomplete), but the guards (D-15~D-21) are implemented in `test_deploy_freshness.py` and verified passing (297 collected, 7 new pass). This is a tracking discrepancy - the work is done but the checkbox is not toggled. Recommend `- [x]`.
|
||||
|
||||
### 3.5 `tests/test_deploy_freshness.py`
|
||||
|
||||
**D-11 cleanup**: Removed the unused `recognized` set (which contained `MAM_MQTT_HOST` for exclusion-checking that was never exercised) and added `MQTT_BIND` to `valid_mqtt_vars`. Verified `MAM_MQTT_HOST` appears nowhere in the codebase. The test only checks `MQTT_*`-prefixed vars (regex `\b(MQTT_[A-Z0-9_]+)\b`), so `NATS_BIND`/`WS_BIND` are correctly excluded from validation. PASS
|
||||
|
||||
**D-15~D-21 new guards**: All 7 guards pass. Verified:
|
||||
- D-15: store_dir absolute path + unquoted heredoc PASS
|
||||
- D-16: nats image alpine-pinned (no `latest`) PASS
|
||||
- D-17: port 8222 bound to 127.0.0.1 PASS
|
||||
- D-18: TLS blocks use DNS names, not IP literals PASS
|
||||
- D-19: subject literals match `DEFAULT_TOPIC_ROOT` (`python.mqtt.jobs`) PASS
|
||||
- D-20: `run_loop.sh` exports `MAM_ENV_FILE` (verified line 106) PASS
|
||||
- D-21: `.mam.env.example` documents `MQTT_KEEPALIVE`; no uncommented `MQTT_RETRY_INTERVAL`/`MQTT_MAX_RETRIES` PASS
|
||||
|
||||
**Finding**:
|
||||
|
||||
**L-3 (Low) - D-19 regex is brittle**: `re.findall(r'["\'](python\.mqtt\.jobs\.[>*\w.]+)["\']', content)` scans the entire markdown (not just code blocks) and matches subject literals in quoted strings. If a future prose sentence contains a quoted subject like `"python.mqtt.jobs.test"` without a wildcard, it would be validated. Currently passes but the scope is broader than "config examples". Non-blocking.
|
||||
|
||||
---
|
||||
|
||||
## 4. Task Deliverable Coverage
|
||||
|
||||
| Requirement | Status | Location |
|
||||
|---|---|---|
|
||||
| Production Docker Compose (MQTT 1883, NATS 4222, WS 8080, HTTP 8222, JetStream volume, healthchecks) | Partial | section 9.2 compose has 1883/8222/8080 + healthcheck + nats-data volume; **missing 4222** (M-1) |
|
||||
| Production nats.conf (ports, JetStream, healthcheck endpoint) | PASS | section 9.1 nats.conf |
|
||||
| Remote networking & security (UFW, TLS/Certbot vs Tailscale, user auth) | PASS | section 9.3 comparison table + UFW rules + section 9.1 accounts/permissions |
|
||||
| Client configuration (.mam.env) | PASS | section 6 `.mam.env` template + `.mam.env.example` MQTT_KEEPALIVE |
|
||||
| Remote verification playbooks (ping, latency, pub/sub) | PASS | section 9.4 R-1~R-10 + WAN latency probe |
|
||||
| Phased rollout roadmap (M2 local spike + remote switchover) | PASS | implementation_plan.md M2a/M2b + section 5 Track 1R roadmap |
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Validation Summary
|
||||
|
||||
| Suite | Tests | Result |
|
||||
|---|---|---|
|
||||
| `tests/test_deploy_freshness.py` (full) | 20 | PASS 20 passed (13.55s) |
|
||||
| D-15~D-21 (new guards) | 7 | PASS 7 passed (0.02s) |
|
||||
| `tests/test_tier1_unit.py` + `test_o2_race_free_lock.py` | 67 | PASS 67 passed (19.08s) |
|
||||
| `tests/test_sanity.py` | 2 | PASS 2 passed (9.73s) |
|
||||
| `--collect-only` (full suite) | 297 | PASS 297 collected |
|
||||
| `bash -n .agents/skills/lib.sh` | - | PASS syntax OK |
|
||||
|
||||
Full suite (297 tests) not executed end-to-end due to 30s tool timeout; tier2/3/4 tests require a live broker. The fast subset (89 tests across deploy, tier1, o2, sanity) passes cleanly with no regressions.
|
||||
|
||||
---
|
||||
|
||||
## 6. Findings Summary
|
||||
|
||||
| ID | Severity | File | Description | Fix |
|
||||
|---|---|---|---|---|
|
||||
| **M-1** | Medium | PRIVATE_SERVER.md section 9.2 | Production compose omits NATS 4222; contradicts section 9.3 UFW rule and R-3 test | Add `4222:4222` port mapping to section 9.2, or remove 4222 from section 9.3/R-3 |
|
||||
| L-1 | Low | lib.sh:1519 | `handle_startup_dialogs` call adds ~1s/iteration to `wait_for_tui_ready` | Acceptable; consider gating on `_pane_dialog_open` first |
|
||||
| L-2 | Low | lib.sh:1738 | Default timeout halved (40s->20s) via sleep 2->1 | Acceptable for Claude; verify slow-env tolerance |
|
||||
| L-3 | Low | test_deploy_freshness.py D-19 | Regex scans full markdown, not just code blocks | Narrow to code_blocks scope if desired |
|
||||
| V-1 | Very Low | PRIVATE_SERVER.md:73 | Misleading store_dir comment ("$HOME expansion") | Clarify comment |
|
||||
| V-2 | Very Low | implementation_plan.md | Guard checkbox unchecked despite work done | Toggle to `[x]` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation
|
||||
|
||||
The changeset is production-ready for the M2b documentation milestone. The only Medium finding (M-1: section 9.2 missing 4222) is a documentation inconsistency resolvable by a one-line compose edit or removing the corresponding UFW/R-3 reference - no re-planning required. All 7 new guards pass; no test regressions; bash syntax valid; codebase cross-references (`mqtt_common.py` DEFAULT_TOPIC_ROOT, MQTT_KEEPALIVE; `run_loop.sh` MAM_ENV_FILE) all verified.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -1516,6 +1516,9 @@ wait_for_tui_ready() {
|
||||
fi
|
||||
local i
|
||||
for i in {1..30}; do
|
||||
if [ "$agent" = "claude" ]; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
fi
|
||||
if _pane_dialog_open "$sess"; then
|
||||
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter || true
|
||||
@@ -1719,7 +1722,7 @@ handle_startup_dialogs() {
|
||||
local sess="$1" timeout="${2:-20}" waited=0 pane
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
pane=$(_pane_tail "$sess" 20)
|
||||
if printf '%s\n' "$pane" | grep -q 'Do you trust the files'; then
|
||||
if printf '%s\n' "$pane" | grep -Eq 'Do you trust the files|Yes, I trust this folder|Quick safety check'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
|
||||
_sks_herdr send-keys -t "$sess" Down
|
||||
@@ -1727,11 +1730,13 @@ handle_startup_dialogs() {
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -Eq "$_MAM_READY_TOKENS_CLAUDE"; then
|
||||
elif printf '%s\n' "$pane" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -Eq "${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
#default: hermes
|
||||
# MQTT_CLIENT_ID_PREFIX=hermes
|
||||
|
||||
# MQTT keepalive interval (seconds). Used by paho-mqtt client connections.
|
||||
#default: 60
|
||||
# MQTT_KEEPALIVE=60
|
||||
|
||||
# Log level for MAM runtime components (DEBUG, INFO, WARN, ERROR).
|
||||
#default: INFO
|
||||
# MAM_LOG_LEVEL=INFO
|
||||
|
||||
+242
-32
@@ -70,7 +70,7 @@ server_name: mam-hub
|
||||
|
||||
# JetStream 영속 스토리지 (MQTT QoS 1 및 Retained 메시지 처리에 필수)
|
||||
jetstream {
|
||||
store_dir: "~/.local/share/nats/data" # Docker 환경에서는 "/data"로 매핑
|
||||
store_dir: "/data" # Docker 환경 기본 스토리지 (네이티브 실행 시 $HOME 전개)
|
||||
max_file: 10G # 홈랩 디스크 상한 설정
|
||||
}
|
||||
|
||||
@@ -93,17 +93,17 @@ websocket {
|
||||
|
||||
**단일 Docker 실행:**
|
||||
```bash
|
||||
# 호스트에 nats.conf 생성 후 실행
|
||||
# 호스트에 nats.conf 생성 후 실행 (D-2: alpine 고정 핀, D-3: 루프백/바인드 한정)
|
||||
docker run -d \
|
||||
--name mam-nats \
|
||||
--restart unless-stopped \
|
||||
-p 1883:1883 \
|
||||
-p 4222:4222 \
|
||||
-p 8222:8222 \
|
||||
-p 8080:8080 \
|
||||
-p "${MQTT_BIND:-127.0.0.1}:1883:1883" \
|
||||
-p "${NATS_BIND:-127.0.0.1}:4222:4222" \
|
||||
-p "127.0.0.1:8222:8222" \
|
||||
-p "${WS_BIND:-127.0.0.1}:8080:8080" \
|
||||
-v ./nats.conf:/etc/nats/nats.conf:ro \
|
||||
-v nats-data:/data \
|
||||
nats:latest \
|
||||
nats:2.12-alpine \
|
||||
-c /etc/nats/nats.conf
|
||||
```
|
||||
|
||||
@@ -113,15 +113,15 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: nats:latest
|
||||
image: nats:2.12-alpine
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
ports:
|
||||
- "1883:1883" # MQTT 3.1.1 포트 (평면 A: MAM)
|
||||
- "4222:4222" # NATS 기본 포트 (평면 B)
|
||||
- "8222:8222" # HTTP 모니터링 (/varz, /jsz)
|
||||
- "8080:8080" # WebSocket (평면 B)
|
||||
- "${MQTT_BIND:-127.0.0.1}:1883:1883" # MQTT 3.1.1 포트 (평면 A: MAM)
|
||||
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 기본 포트 (평면 B)
|
||||
- "127.0.0.1:8222:8222" # HTTP 모니터링 (/varz, /jsz, /healthz)
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (평면 B)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
@@ -138,11 +138,11 @@ macOS의 sealed APFS 루트 볼륨(`/data`) 권한 문제를 방지하기 위해
|
||||
# 설정 및 데이터 디렉터리 생성 (sudo 불필요)
|
||||
mkdir -p ~/.config/nats ~/.local/share/nats/data
|
||||
|
||||
# 설정 파일 작성
|
||||
cat <<'EOF' > ~/.config/nats/nats.conf
|
||||
# 설정 파일 작성 (D-1: 비인용 heredoc <<EOF 으로 $HOME 을 파일 생성 시점에 절대경로로 고정)
|
||||
cat <<EOF > ~/.config/nats/nats.conf
|
||||
server_name: mam-hub
|
||||
jetstream {
|
||||
store_dir: "~/.local/share/nats/data"
|
||||
store_dir: "$HOME/.local/share/nats/data"
|
||||
max_file: 10G
|
||||
}
|
||||
http_port: 8222
|
||||
@@ -215,6 +215,8 @@ persistence_location /mosquitto/data/
|
||||
- `nats-server`는 내부적으로 MQTT 토픽(`/`)을 NATS Subject(`.`)로 실시간 자동 변환합니다.
|
||||
- MAM 에이전트가 MQTT 토픽 `python/mqtt/jobs/<job_id>/events`로 이벤트를 발행하면, 웹 브라우저나 타 프로젝트의 NATS 구독자는 NATS Subject `python.mqtt.jobs.<job_id>.events` 또는 `python.mqtt.jobs.*.events`로 즉시 실시간 수신할 수 있습니다.
|
||||
- **실용적 이점**: MAM 소스 코드를 단 1줄도 수정하지 않고도 React/Vue 웹 대시보드나 터미널 모니터링 툴을 즉시 부착할 수 있습니다.
|
||||
- **경계 (필수 인지 — N-1)**: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. MQTT의 retained 메시지는 MQTT 구독자에게만 전달되므로(`mqttSendRetainedMsgsToNewSubs`가 MQTT SUBSCRIBE 경로 전용), 잡이 끝난 뒤 접속한 NATS/WebSocket 대시보드는 그 잡의 **종료 이벤트를 수신하지 못합니다**. 사후 상태가 필요하면 (a) 대시보드를 MQTT(1883)로 연결하거나 (b) §5.3 JetStream 리플레이 스트림을 옵트인하십시오.
|
||||
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject가 보이지 않습니다.
|
||||
- **주의 사항**: 토픽 레벨 내에 마침표(`.`)가 포함되면 NATS 계층에서 토큰이 분리될 수 있으나, MAM의 `job_id`는 8자리 hex, 워크스페이스 지문은 12자리 hex이므로 안전합니다.
|
||||
|
||||
### 5.3 JetStream 이벤트 리플레이 (Event Replay)
|
||||
@@ -227,7 +229,7 @@ persistence_location /mosquitto/data/
|
||||
|
||||
### 5.5 멀티테넌트 계정 분리 및 보안
|
||||
- 단일 서버 내에서 `MAM` 전용 계정과 `HOME` 개인 계정을 분리하여 리소스 쿼터와 권한을 완벽히 격리할 수 있습니다.
|
||||
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고, 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
|
||||
- **권고 배치**: MAM과 이를 관측하는 대시보드는 동일한 계정(`MAM`)에 배치하고(관측자는 읽기 전용 `mam_observer` 역할 부여), 무관한 홈랩 서비스는 별도 계정(`HOME`)에 배치합니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -243,23 +245,21 @@ persistence_location /mosquitto/data/
|
||||
# MAM Private MQTT Broker Configuration (.mam.env)
|
||||
# ==============================================================================
|
||||
|
||||
# 개인 서버 IP 또는 도메인
|
||||
MQTT_BROKER="192.168.1.100" # 예: 10.0.0.5, mqtt.my-domain.com 등
|
||||
|
||||
# MQTT 기본 포트 (평문 TCP: 1883, TLS 암호화: 8883)
|
||||
# ── 모델 T (Tailscale 사설 오버레이망 권장) ───────────────────────
|
||||
MQTT_BROKER="mam-hub.tailXXXX.ts.net" # 또는 100.x.y.z (평문이므로 IP 사용 가능)
|
||||
MQTT_PORT=1883
|
||||
|
||||
# TLS 암호화 활성화 여부 (0: 평문 TCP, 1: TLS 암호화)
|
||||
MQTT_TLS=0
|
||||
MQTT_USERNAME=mam_agent
|
||||
MQTT_PASSWORD=replace_me_with_token
|
||||
MQTT_KEEPALIVE=60 # WAN 구간 권장 연결 유지 시간
|
||||
|
||||
# 인증 설정 (익명 브로커는 주석 처리 또는 빈 문자열 유지)
|
||||
# MQTT_USERNAME=my_agent_user
|
||||
# MQTT_PASSWORD=my_secure_password
|
||||
|
||||
# TLS 인증서 경로 (MQTT_TLS=1 설정 시 사용)
|
||||
# MQTT_CA_CERTS=/path/to/ca.crt
|
||||
# MQTT_CERTFILE=/path/to/client.crt
|
||||
# MQTT_KEYFILE=/path/to/client.key
|
||||
# ── 모델 P (공개 TLS / Let's Encrypt 모델) ───────────────────────
|
||||
# MQTT_BROKER="mam-broker.example.com" # D-4: 반드시 인증서 SAN 의 DNS 이름 (IP 금지)
|
||||
# MQTT_PORT=8883
|
||||
# MQTT_TLS=1
|
||||
# MQTT_USERNAME=mam_agent
|
||||
# MQTT_PASSWORD=replace_me_with_token
|
||||
# MQTT_CA_CERTS 는 Let's Encrypt 사용 시 '설정하지 않음' (시스템 신뢰 저장소 사용)
|
||||
```
|
||||
|
||||
*참고: OS 환경변수에 동일한 이름이 이미 `export`되어 있는 경우 OS 환경변수가 `.mam.env` 파일 설정보다 우선합니다.*
|
||||
@@ -273,10 +273,10 @@ MQTT_TLS=0
|
||||
### Step 1. 브로커 리스너 및 JetStream 상태 확인
|
||||
```bash
|
||||
# MQTT 리스너 활성화 확인
|
||||
curl -s http://192.168.1.100:8222/varz | grep -i mqtt
|
||||
curl -s http://127.0.0.1:8222/varz | grep -i mqtt
|
||||
|
||||
# JetStream 엔진 정상 구동 확인
|
||||
curl -s http://192.168.1.100:8222/jsz
|
||||
curl -s http://127.0.0.1:8222/jsz
|
||||
```
|
||||
|
||||
### Step 2. 임시 잡 등록 및 연결 검증 이벤트 발행
|
||||
@@ -325,3 +325,213 @@ Step 2의 `-v` 출력 로그 또는 `.mam/delegate_job_logs/$JID/events.ndjson`
|
||||
1. **Phase 1 (Track 0 선행 패치)**: `publish_event.py`와 `job_subscriber.py`의 로컬 디스크 폴백(`B-14`, `B-15`)을 먼저 적용하여 브로커 다운 시에도 루프가 멈추지 않는 방탄 구조를 확립합니다.
|
||||
2. **Phase 2 (개인 브로커 가동)**: 개인 서버에 `nats-server -c nats.conf`를 구동하고 `.mam.env`에 `MQTT_BROKER`를 연결합니다.
|
||||
3. **Phase 3 (A-2 보안 완전 종결)**: 워크스페이스 지문 토픽(`mam/<sha256[:12]>/jobs/...`) 및 무조건 토큰 발급을 적용하여 공개 브로커 위험을 완전히 영구 폐기합니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 원격 서버 `nats-server` Docker 프로덕션 배포 가이드 (Track 1R)
|
||||
|
||||
원격 VPS 또는 상시 가동 홈랩 서버에 프로덕션 수준의 `nats-server`를 Docker 기반으로 구축하고 MAM과 연동하는 표준 절차입니다.
|
||||
|
||||
### 9.1 프로덕션 `nats.conf`
|
||||
|
||||
```conf
|
||||
# nats.conf — 원격 프로덕션 (컨테이너 내부 절대경로 기준)
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. MAM 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # D-1: 절대경로 고정. '~' 도, 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # D-3: 호스트 게시는 loopback 한정 (§9.3)
|
||||
|
||||
mqtt {
|
||||
port: 1883 # 공개 TLS 모델에서는 8883 + tls 블록 (§9.3)
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 기본 100 → 다중 에이전트 동시 발행 여유
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 (Rev.2: C1/C2 반영) ────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. PRIVATE_SERVER.md §5.5 의 '동일 계정 배치' 처방
|
||||
{ user: mam_observer, password: $MAM_OBSERVER_PASS,
|
||||
permissions: {
|
||||
subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam.<fp>.jobs.>"
|
||||
publish: { deny: [">"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
# MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리)
|
||||
HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] }
|
||||
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
|
||||
}
|
||||
system_account: SYS
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **관측자는 반드시 `MAM` 계정 안에 둡니다.** NATS 계정은 하드 격리 경계이므로 `user: home`(계정 `HOME`)으로 접속한 클라이언트는 `python.mqtt.jobs.>`를 구독해도 **0건**을 받습니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 **부록 X(Option A)** 를 따르십시오.
|
||||
|
||||
### 9.2 프로덕션 `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # D-2: latest(=scratch)는 healthcheck 불가
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
environment:
|
||||
# 미해결 $VAR 는 파싱 에러 → 시크릿 누락 시 '기동 실패'로 fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set in .env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set in .env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set in .env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set in .env}
|
||||
ports:
|
||||
- "${MQTT_BIND:-127.0.0.1}:1883:1883"
|
||||
- "${NATS_BIND:-127.0.0.1}:4222:4222" # NATS 네이티브 프로토콜 (Plane B)
|
||||
- "127.0.0.1:8222:8222" # D-3: 무인증 모니터링은 loopback 한정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080"
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Docker의 published 포트는 UFW를 우회합니다.** Docker가 삽입하는 NAT/FORWARD 규칙이 `ufw`의 INPUT 체인보다 먼저 평가되므로, `ufw deny 1883`을 걸어도 `-p 1883:1883`으로 게시한 포트는 인터넷에 열립니다. 본 구성이 방화벽에만 의존하지 않고 **published 포트 자체에 바인드 주소를 명시**(`${MQTT_BIND}:1883:1883`)하는 이유입니다.
|
||||
|
||||
### 9.3 원격 네트워킹 & 보안 가이드
|
||||
|
||||
| 항목 | 🏆 **모델 T: Tailscale/WireGuard 오버레이 (권장)** | 모델 P: 공개 TLS (Let's Encrypt) |
|
||||
|---|---|---|
|
||||
| 인터넷 노출 면적 | **0** (공개 리스너 없음) | 8883 1개 |
|
||||
| 인증서 필요 | 불필요 (`MQTT_TLS=0`) | 필수 + 90일 갱신 |
|
||||
| **D-4 호스트명 제약** | **해당 없음** | 도메인 필수, IP 불가 |
|
||||
| 도메인 필요 | 불필요 | **필수** |
|
||||
| 이동성 | 자동 (Tailscale mesh) | 자동 |
|
||||
|
||||
**방화벽 (UFW) 설정 (모델 T):**
|
||||
```bash
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow 22/tcp
|
||||
|
||||
# tailnet 인터페이스만 허용
|
||||
sudo ufw allow in on tailscale0 to any port 1883 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 4222 proto tcp
|
||||
sudo ufw allow in on tailscale0 to any port 8080 proto tcp
|
||||
|
||||
sudo ufw enable && sudo ufw status verbose
|
||||
```
|
||||
|
||||
**시크릿 생성 및 바인드 주소 주입 (`.env`):**
|
||||
```bash
|
||||
# 서버 측 compose 옆 .env
|
||||
cat <<EOF > .env
|
||||
MQTT_BIND=$(tailscale ip -4)
|
||||
WS_BIND=$(tailscale ip -4)
|
||||
MAM_BROKER_PASS=$(openssl rand -base64 32)
|
||||
MAM_OBSERVER_PASS=$(openssl rand -base64 32)
|
||||
HOME_BROKER_PASS=$(openssl rand -base64 32)
|
||||
SYS_BROKER_PASS=$(openssl rand -base64 32)
|
||||
EOF
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
### 9.4 원격 검증 플레이북 (R-1 ~ R-10)
|
||||
|
||||
| ID | 검증 항목 | 방법 | 통과 기준 |
|
||||
|---|---|---|---|
|
||||
| **R-1** | 브로커 헬스 | SSH 터널 후 `curl -sf http://127.0.0.1:8222/healthz` | **HTTP 200** |
|
||||
| **R-2** | 리스너 + TLS 신원 | `curl -s .../varz \| grep -i mqtt`; 모델 P는 `openssl s_client -connect H:8883 -servername H` | MQTT 리스너 노출, SAN에 `MQTT_BROKER` 포함 |
|
||||
| **R-3** | 노출 면적 단언 | 외부 망에서 `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 전부 closed/filtered |
|
||||
| **R-4** | 왕복 pub/sub + 계정 JetStream | 임시 잡 등록 → 구독자 기동 → `progress` 발행 → 수신 → 종결 | 수신 성공, `JetStream not enabled for account` **미발생** |
|
||||
| **R-5** | **retained 종료 이벤트 (MQTT)** | `--event completed` 발행 **후** 신규 `job_subscriber.py` 기동 | 즉시 최종 이벤트 수신 |
|
||||
| **R-6** | 브로커 신원 단언 | `grep -c 'broker.hivemq.com' .mam/delegate_job_logs/$JID/events.ndjson` | **0**, 원격 호스트 등장 |
|
||||
| **R-7** | freeze 회귀 (H-1/H-4) | freeze 경로 `publish_event.py`를 `MAM_ENV_FILE` 없이 실행 / 오타 경로 실행 | 공개 브로커로 나가지 않음 |
|
||||
| **R-8** | 전체 회귀 스위트 | `.venv/bin/python -m pytest tests/ -q` | **전건 통과** |
|
||||
| **R-9** | **관측자 계정 경계** | `mam_observer`로 NATS 구독 → 수신 확인. 이어서 `home`으로 동일 구독 | `mam_observer` **수신**, `home` **0건** |
|
||||
| **R-10** | **retained 경계 확인 (N-1)** | 잡 종료 **후** NATS 네이티브 구독자를 새로 붙임 | **0건 수신** (retained는 MQTT 전용) |
|
||||
|
||||
**WAN 지연 시간 측정 (Latency Probe):**
|
||||
```bash
|
||||
.venv/bin/python - <<'PY'
|
||||
import sys, time, statistics
|
||||
sys.path.insert(0, '.agents/skills/multi-agent-mux-delegate-job/scripts')
|
||||
import mqtt_common as m
|
||||
cfg = m.broker_config_from_env()
|
||||
print(f"target: {cfg.host}:{cfg.port} tls={cfg.tls}")
|
||||
conn, rtt = [], []
|
||||
for _ in range(5):
|
||||
c = m.make_client("latency", cfg)
|
||||
t0 = time.perf_counter(); c.connect(cfg.host, cfg.port, 10); c.loop_start()
|
||||
conn.append((time.perf_counter() - t0) * 1000)
|
||||
t1 = time.perf_counter()
|
||||
info = c.publish("mam/latency/probe", b"x", qos=1); info.wait_for_publish(10)
|
||||
rtt.append((time.perf_counter() - t1) * 1000)
|
||||
c.loop_stop(); c.disconnect()
|
||||
print(f"connect p50={statistics.median(conn):.1f}ms max={max(conn):.1f}ms")
|
||||
print(f"qos1 rtt p50={statistics.median(rtt):.1f}ms max={max(rtt):.1f}ms")
|
||||
PY
|
||||
```
|
||||
|
||||
### 9.5 전환(Cutover) 절차 (H-2 대응)
|
||||
|
||||
```
|
||||
[1 드레인] ──> [2 잔여 스캔] ──> [3 .mam.env 교체] ──> [4 R-1~R-10] ──> [5 레거시 차단]
|
||||
```
|
||||
1. **드레인**: 신규 위임 중단, 진행 중 잡이 모두 terminal 될 때까지 대기.
|
||||
2. **잔여 스캔**: 옛 브로커에 핀 고정된 레코드 스캔 후 정리.
|
||||
3. **`.mam.env` 교체**: 원격 브로커 주소 및 토큰 적용.
|
||||
4. **검증**: R-1 ~ R-10 전건 통과 확인.
|
||||
5. **레거시 차단**: 공개 브로커 설정 완전 제거.
|
||||
|
||||
---
|
||||
|
||||
### 부록 X. Option A (계정 간 Export / Import) 예외 경로
|
||||
|
||||
관측자가 **서로 다른 신뢰 도메인**에 속해 계정을 엄격히 분리해야 하는 경우:
|
||||
|
||||
```conf
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled
|
||||
users: [ { user: mam_agent, password: $MAM_BROKER_PASS } ]
|
||||
exports: [ { stream: "python.mqtt.jobs.>", accounts: [HOME] } ]
|
||||
}
|
||||
HOME: {
|
||||
users: [ { user: home, password: $HOME_BROKER_PASS } ]
|
||||
imports: [ { stream: { account: MAM, subject: "python.mqtt.jobs.>" } } ]
|
||||
}
|
||||
SYS: { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
|
||||
}
|
||||
system_account: SYS
|
||||
```
|
||||
|
||||
|
||||
+42
-22
@@ -20,6 +20,7 @@
|
||||
|---|---|---|---|
|
||||
| **Track 0** | `B-14`, `B-15` (P1) | 브로커 다운 시 65분 정지(Hang) 및 오판정 방지 (로컬 디스크 내결함성) | `publish_event.py`, `job_subscriber.py`, `multi-agent-mux-delegate-job` |
|
||||
| **Track 1** | `O-5` (P2) | `nats-server` MQTT 3.1.1 어댑터 호환성 및 Retained 메시지 실측 검증 | 격리 클론 (`$SCRATCH/nats-spike`) |
|
||||
| **Track 1R** | 원격 프로덕션 (P1) | VPS/홈랩 `nats-server` Docker 상시 가동 및 MAM 원격 백플레인 전환 | `PRIVATE_SERVER.md` §9, 서버측 `docker-compose.yml`/`nats.conf`, `.mam.env` |
|
||||
| **Track 2** | `A-2`, `B-16` (P2) | 워크스페이스 지문 토픽 격리 및 `auth_token` 무조건 발급 강제 | `mqtt_common.py`, `registry.py`, `reconcile.sh` |
|
||||
| **Track 3** | 문서/설정 동기화 | 공식 가이드, 배포 스크립트, 환경변수 템플릿 일원화 | `MESSAGING.md`, `IMPROVEMENTS.md`, `VERSIONS.md`, `deploy/*` |
|
||||
|
||||
@@ -30,15 +31,16 @@
|
||||
각 마일스톤은 완료 정의(DoD)와 엄격한 게이트(Gate)를 가지며, 게이트 조건을 충족하지 못하면 다음 마일스톤으로 진입할 수 없습니다.
|
||||
|
||||
```
|
||||
M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2 (Track 1 실증) ──> M3 (Track 2 보안) ──> M4 (Track 3 완결)
|
||||
M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2a (로컬 스파이크) ──> M2b (원격 배포) ──> M3 (Track 2 보안) ──> M4 (Track 3 완결)
|
||||
```
|
||||
|
||||
| 마일스톤 | 이름 | 완료 정의 (Definition of Done) | 통과 게이트 (Gate Condition) |
|
||||
|---|---|---|---|
|
||||
| **M0** | 문서 정합성 확보 | `PRIVATE_SERVER.md` E-1~E-4 교정, 다능성 절 추가, 본 로드맵 작성 | **G-D1 ~ G-D4 가드 테스트 통과** (276 -> 280) |
|
||||
| **M1** | 내결함성 확보 (Track 0) | `B-14`, `B-15` 코드 패치 완료 | **G-1 ~ G-10 가드 통과 + mutation 전건 FAIL 확인** (280 -> 290) |
|
||||
| **M2** | 브로커 실증 (Track 1) | 격리 클론에서 S-1 ~ S-9 스파이크 완수 | **S-3(Retained Terminal Event) 통과** (실패 시 mosquitto로 분기) |
|
||||
| **M3** | 보안 종결 (Track 2) | A-2 지문 토픽 전환, G-11 무조건 토큰 발급 | 지문 토픽 동작 확인 **후** legacy 구독 제거 (290 -> 291) |
|
||||
| **M2a** | 로컬 스파이크 (Track 1) | 격리 클론에서 S-1 ~ S-9 스파이크 완수 | **S-3(Retained Terminal Event) 통과** (실패 시 mosquitto로 분기) |
|
||||
| **M2b** | 원격 프로덕션 전환 (Track 1R) | D-1~D-5 교정 + §9 원격 배포 + §9.5 전환 | **R-3(노출0) · R-5(retained) · R-6(신원) · R-9(계정격리) 동시 통과** |
|
||||
| **M3** | 보안 종결 (Track 2) | A-2 지문 토픽 전환, G-11 무조건 토큰 발급 | 지문 토픽 동작 확인 **후** legacy 구독 제거 |
|
||||
| **M4** | 동기화 완료 (Track 3) | `MESSAGING.md`, `IMPROVEMENTS.md`, `VERSIONS.md`, `deploy/*` 정합 | 전체 테스트 스위트 100% Green |
|
||||
|
||||
---
|
||||
@@ -102,7 +104,27 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2 (Track 1 실
|
||||
|
||||
---
|
||||
|
||||
## 5. Track 2: A-2 보안 결함 및 워크스페이스 격리 해소 (`A-2`, `B-16`)
|
||||
## 5. Track 1R: 원격 프로덕션 전환 로드맵 (M2b 상세)
|
||||
|
||||
```
|
||||
[P0 교정] D-1~D-5 문서 교정 + §5.2 경계 명문화 + 신규 가드 7종
|
||||
▼
|
||||
[P1 서버 준비] VPS/홈랩 프로비저닝, Docker, Tailscale 가입
|
||||
▼
|
||||
[P2 배포] nats.conf + compose 기동, healthcheck healthy 확인
|
||||
▼
|
||||
[P3 잠금] 바인드 주소 한정 + UFW + ss/nmap 로 노출 면적 0 단언 (R-3)
|
||||
▼
|
||||
[P4 전환] 드레인 → 잔여 스캔 → .mam.env 교체 (§9.5)
|
||||
▼
|
||||
[P5 검증] R-1 ~ R-10. R-5(retained) / R-9(계정 경계) 를 최종 관문으로
|
||||
▼
|
||||
[P6 상시화] 로그 로테이션, JetStream 볼륨 백업, healthcheck 알림
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Track 2: A-2 보안 결함 및 워크스페이스 격리 해소 (`A-2`, `B-16`)
|
||||
|
||||
1. **`auth_token` 무조건 발급 (`F-3` / `G-11`)**:
|
||||
`registry.register_job()`에서 브로커 설정과 무관하게 항상 `secrets.token_urlsafe(32)` 기반 토큰을 발급하여 공개 브로커 환경에서도 HMAC 검증이 무력화되지 않도록 강제합니다.
|
||||
@@ -113,12 +135,12 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2 (Track 1 실
|
||||
|
||||
---
|
||||
|
||||
## 6. Track 3: 문서 및 배포 설정 동기화
|
||||
## 7. Track 3: 문서 및 배포 설정 동기화
|
||||
|
||||
| 대상 파일 | 갱신 내용 |
|
||||
|---|---|
|
||||
| [`MESSAGING.md`](MESSAGING.md) | 브로커 표준을 `nats-server`로 갱신, F-1/C1 해소 기록, F-5 영속 세션 서술 정정 |
|
||||
| [`IMPROVEMENTS.md`](IMPROVEMENTS.md) | A-2 완료 전환, B-14/B-15/B-16/O-5 해결 상태 갱신 |
|
||||
| [`IMPROVEMENTS.md`](IMPROVEMENTS.md) | A-2 완료 전환, B-14/B-15/B-16/O-5 해결 상태 갱신, B-17/B-18 신설 등록 |
|
||||
| [`VERSIONS.md`](VERSIONS.md) | `v2.0.0` 릴리스 노트에 메시징 백플레인 고도화 및 내결함성 패치 기록 |
|
||||
| [`PRIVATE_SERVER.md`](PRIVATE_SERVER.md) | 스파이크 결과 반영 및 최종 가이드 확정 |
|
||||
| [`deploy/install.sh`](deploy/install.sh) | `requirements.txt` 확인 (paho 유지) 및 개인 브로커 안내 추가 |
|
||||
@@ -126,21 +148,6 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2 (Track 1 실
|
||||
|
||||
---
|
||||
|
||||
## 7. 의존성 그래프 및 롤백 전략
|
||||
|
||||
```
|
||||
[M0: 문서/가드] ────────────┐
|
||||
│ │ (M0 A-1 환경변수 정렬 선행)
|
||||
▼ ▼
|
||||
[M1: Track 0 내결함성] ──> [M2: Track 1 스파이크] ──> [M3: Track 2 보안] ──> [M4: 동기화]
|
||||
```
|
||||
|
||||
- **롤백 전략**:
|
||||
- `nats-server` 스파이크(S-3) 실패 시: 클라이언트 코드 변경 없이 `.mam.env`의 브로커 주소만 `eclipse-mosquitto`로 전환합니다 (가역성 100%).
|
||||
- Track 0 내결함성 패치는 브로커 제품과 무관하게 순수 이득이므로 롤백하지 않고 영구 유지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 진행 추적 체크리스트
|
||||
|
||||
### M0: 문서 정합성 확보
|
||||
@@ -154,14 +161,27 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2 (Track 1 실
|
||||
- [x] Step 3: `multi-agent-mux-delegate-job` 인프라 `rc=3` 에러 분리 (`F-4` / G-9~G-10)
|
||||
- [x] M1 통합 검증 (브로커 다운 상태 위임 3초 완주)
|
||||
|
||||
### M2: Track 1 `nats-server` 실증 (`O-5`)
|
||||
### M2a: Track 1 `nats-server` 로컬 실증 (`O-5`)
|
||||
- [ ] 격리 클론 생성 (`$SCRATCH/nats-spike`)
|
||||
- [ ] S-1 ~ S-9 스파이크 매트릭스 검증 수행
|
||||
- [ ] S-3 Retained 메시지 게이트 통과 확인
|
||||
|
||||
### M2b: Track 1R 원격 프로덕션 전환
|
||||
- [x] D-1 `store_dir` 절대경로 교정 + 비인용 heredoc (`PRIVATE_SERVER.md:73`, `:146`)
|
||||
- [x] D-2 이미지 핀 `nats:2.12-alpine` + `/healthz` healthcheck
|
||||
- [x] D-3 8222/8080 바인드 주소 한정
|
||||
- [x] D-4 TLS 절의 IP 예시 제거 및 DNS/SAN 요건 명시
|
||||
- [x] D-5 `.mam.env.example` 정합 (`MQTT_KEEPALIVE` 추가)
|
||||
- [x] N-1 §5.2 에 retained=MQTT 전용 경계 명문화 + §9.1 `mam_observer` 추가
|
||||
- [ ] 신규 가드 G-D5 ~ G-D9, G-R1, G-R2 구현 및 검증 (290 -> 297)
|
||||
- [ ] 서버 배포 및 R-3 노출 면적 0 단언
|
||||
- [ ] §9.5 드레인·잔여 스캔 후 `.mam.env` 전환
|
||||
- [ ] R-1 ~ R-10 전건 통과 (R-5 / R-9 최종 관문)
|
||||
|
||||
### M3: Track 2 보안 및 토픽 격리 (`A-2`, `B-16`)
|
||||
- [ ] G-11 무조건 `auth_token` 발급 적용
|
||||
- [ ] 워크스페이스 지문 토픽 발행 전환 및 레거시 구독 제거
|
||||
|
||||
### M4: Track 3 문서 및 배포 동기화
|
||||
- [ ] `MESSAGING.md`, `IMPROVEMENTS.md`, `VERSIONS.md`, `deploy/*` 최종 갱신
|
||||
|
||||
|
||||
@@ -283,16 +283,11 @@ def test_d11_private_server_env_names_valid():
|
||||
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
||||
assert code_blocks, "No code blocks found in PRIVATE_SERVER.md"
|
||||
|
||||
# Known recognized MQTT env vars from broker_config_from_env()
|
||||
recognized = {
|
||||
"MQTT_BROKER", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
|
||||
"MQTT_CLIENT_ID_PREFIX", "MQTT_CA_CERTS", "MQTT_CERTFILE", "MQTT_KEYFILE",
|
||||
"MQTT_KEEPALIVE", "MAM_MQTT_HOST" # checked for exclusion
|
||||
}
|
||||
# Known recognized MQTT env vars from broker_config_from_env() and deployment
|
||||
valid_mqtt_vars = {
|
||||
"MQTT_BROKER", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
|
||||
"MQTT_CLIENT_ID_PREFIX", "MQTT_CA_CERTS", "MQTT_CERTFILE", "MQTT_KEYFILE",
|
||||
"MQTT_KEEPALIVE"
|
||||
"MQTT_KEEPALIVE", "MQTT_BIND"
|
||||
}
|
||||
|
||||
for block in code_blocks:
|
||||
@@ -352,3 +347,117 @@ def test_d14_private_server_cli_args_valid():
|
||||
)
|
||||
assert "status --job " in code_blocks, "PRIVATE_SERVER.md must include cleanup step with status --job"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-15 — (G-D5) store_dir in code blocks must be absolute path (/ or $HOME)
|
||||
# and heredocs writing it must be unquoted (<<EOF).
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d15_private_server_store_dir_valid():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
||||
for i, block in enumerate(code_blocks):
|
||||
for match in re.finditer(r'store_dir:\s*["\']?([^"\'\n]+)["\']?', block):
|
||||
val = match.group(1).strip()
|
||||
assert val.startswith("/") or val.startswith("$HOME"), (
|
||||
f"Block #{i+1} store_dir '{val}' must start with '/' or '$HOME' (no literal ~)"
|
||||
)
|
||||
if "store_dir:" in block and "cat <<" in block:
|
||||
assert "<<'EOF'" not in block, (
|
||||
f"Block #{i+1} writes store_dir with quoted heredoc <<'EOF', which prevents $HOME expansion"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-16 — (G-D6) nats image references in code fences must use pinned alpine
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d16_private_server_nats_image_alpine_pinned():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
||||
for i, block in enumerate(code_blocks):
|
||||
nats_refs = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', block)
|
||||
for tag in nats_refs:
|
||||
assert tag != "latest", f"Block #{i+1} contains unpinned 'nats:latest'"
|
||||
assert "-alpine" in tag or tag.startswith("2."), f"Block #{i+1} nats image '{tag}' must use alpine variant"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-17 — (G-D7) Port 8222 in docker examples must be bound to 127.0.0.1
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d17_private_server_monitoring_port_localhost_bound():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
||||
for i, block in enumerate(code_blocks):
|
||||
for match in re.finditer(r'["\']?([0-9a-zA-Z._$:-]*8222:8222)["\']?', block):
|
||||
mapping = match.group(1).strip()
|
||||
assert "127.0.0.1:8222:8222" in mapping, (
|
||||
f"Block #{i+1} port 8222 must be bound to 127.0.0.1, got '{mapping}'"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-18 — (G-D8) TLS examples must not use IP literals for MQTT_BROKER
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d18_private_server_tls_examples_use_domain_names():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
code_blocks = re.findall(r"```(?:bash|conf|yaml|)(.*?)```", content, re.DOTALL)
|
||||
for i, block in enumerate(code_blocks):
|
||||
if "MQTT_TLS=1" in block or "port: 8883" in block:
|
||||
for match in re.finditer(r'MQTT_BROKER=["\']?([0-9.]+)', block):
|
||||
ip = match.group(1)
|
||||
assert False, f"Block #{i+1} uses IP literal '{ip}' with TLS (must use DNS domain name for SAN verification)"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-19 — (G-D9) Subject literals in config examples match DEFAULT_TOPIC_ROOT
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d19_private_server_subject_literals_match_default_topic_root():
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
sys.path.insert(0, os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-delegate-job", "scripts"))
|
||||
import mqtt_common
|
||||
expected_prefix = mqtt_common.DEFAULT_TOPIC_ROOT.replace("/", ".")
|
||||
matches = re.findall(r'["\'](python\.mqtt\.jobs\.[>*\w.]+)["\']', content)
|
||||
assert matches, "Expected subject literals matching DEFAULT_TOPIC_ROOT in PRIVATE_SERVER.md"
|
||||
for sub in matches:
|
||||
assert sub.startswith(expected_prefix), f"Subject '{sub}' does not start with expected prefix '{expected_prefix}'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-20 — (G-R1) run_loop.sh exports MAM_ENV_FILE
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d20_run_loop_exports_mam_env_file():
|
||||
run_loop_path = os.path.join(REPO_ROOT, ".agents", "skills", "multi-agent-mux-loop", "scripts", "run_loop.sh")
|
||||
with open(run_loop_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert 'export MAM_ENV_FILE=' in content, "run_loop.sh must export MAM_ENV_FILE"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-21 — (G-R2) MQTT_KEEPALIVE documented and no un-commented retry vars
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d21_env_template_mqtt_var_coverage():
|
||||
template_path = os.path.join(REPO_ROOT, ".mam.env.example")
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
template = f.read()
|
||||
assert "MQTT_KEEPALIVE" in template, ".mam.env.example must document MQTT_KEEPALIVE"
|
||||
for line in template.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("#"):
|
||||
assert "MQTT_RETRY_INTERVAL" not in line
|
||||
assert "MQTT_MAX_RETRIES" not in line
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user