feat(deploy): create production Docker assets in docker/ (compose, nats.conf, env template, README) with D-22~D-30 freshness guards
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
# 📐 구현 계획서 Rev.2: `docker/` 프로덕션 배포 자산 정본화 (Job `2168e631`)
|
||||
|
||||
- **작성일**: 2026-08-23
|
||||
- **역할**: Planner (`.agents/MULTI_AGENT_RULES.md` §1 — Planner 는 저장소 코드/문서를 **수정하지 않으며**, 산출물은 본 계획서입니다)
|
||||
- **기준 커밋**: `3523b9b` (테스트 **297건 수집** 실측)
|
||||
- **선행 리비전**: `810987f6` (Rev.1) ← 본 문서가 대체합니다
|
||||
- **판정 대상 리뷰**: `2934740e` (agy, `[VERDICT: PASS WITH CHALLENGE]`) — WebSocket `same_origin` 기본값 이의제기
|
||||
- **대상 산출물**: `docker/docker-compose.yaml`, `docker/nats.conf`, `docker/.env.example`, `docker/README.md`, `tests/test_deploy_freshness.py` (D-22 ~ **D-30**)
|
||||
- **테스트 수 예상**: 297 → **306** (Rev.1 의 305 에서 D-30 추가)
|
||||
|
||||
---
|
||||
|
||||
## A. 리뷰 판정 (Adjudication of Challenge `2934740e`)
|
||||
|
||||
### A-1. 판정 요약
|
||||
|
||||
| 항목 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| **전제**: "`websocket {}` 를 원점 설정 없이 정의하면 NATS 가 `same_origin: true` 로 기본 동작한다" | ❌ **기각 (사실과 반대)** | `SameOrigin` 은 평범한 `bool` 필드이고 **기본값을 `true` 로 설정하는 코드가 저장소 어디에도 없음**. `checkOrigin()` 은 `!checkSame && listEmpty` 일 때 **즉시 `nil` 반환** |
|
||||
| **귀결**: "브라우저 대시보드가 403 Forbidden 으로 거부된다" | ❌ **기각** | 403 경로는 실재하나(`websocket.go:869`) 도달 조건이 성립하지 않음. 현 설정에서 교차 출처 브라우저 접속은 **그대로 성공** |
|
||||
| **처방 1**: `same_origin: false` 추가 | ⚠️ **부분 수용 (무해하나 no-op)** | 유효한 키이나 기본값과 동일. "꺼야만 동작한다"는 잘못된 서사를 설정 파일에 새기게 됨 → **주석으로 사실을 기록**하는 형태로 변환 수용 |
|
||||
| **처방 2**: `allowed_origins: ["*"]` | 🔴 **강력 기각 — 서버가 기동하지 못함** | `validateWebsocketOptions` 가 `"*"` 의 scheme 이 http/https 가 아니라며 **에러 반환**(`websocket.go:1142-1143`). 이 처방을 따르면 브로커가 **아예 뜨지 않음** |
|
||||
| **처방 3**: Mixed Content(`https://` → `ws://`) 문서화 | ✅ **전면 수용** | NATS 와 무관한 브라우저 정책이며 실제로 홈랩에서 자주 발생. §3.4 트러블슈팅 표에 반영 |
|
||||
| **부수 효과** | 🟢 **신규 발견 N-7** | 챌린지가 지목한 "Plane B 브라우저 대시보드" 영역을 파다가, **8080 이 `/mqtt` 경로로 MQTT-over-WebSocket 도 서빙**한다는 사실을 확인. Rev.2 이래 미해결이던 열린 질문이 이걸로 **해소**됨 |
|
||||
|
||||
### A-2. 전제가 왜 사실과 반대인가 — 실측
|
||||
|
||||
**(1) 구조체 필드 주석이 명시적으로 반대를 말합니다.** `server/opts.go:672-677`:
|
||||
|
||||
```go
|
||||
// If true, the Origin header must match the request's host.
|
||||
SameOrigin bool
|
||||
|
||||
// Only origins in this list will be accepted. If empty and
|
||||
// SameOrigin is false, any origin is accepted.
|
||||
AllowedOrigins []string
|
||||
```
|
||||
|
||||
**(2) 기본값을 `true` 로 세우는 코드가 없습니다.** `SameOrigin = true` / `SameOrigin:` (구조체 리터럴) 로 grep 하면 `opts.go`·`websocket.go` 양쪽에서 **0건**입니다. 값은 오직 설정 파서에서만 대입됩니다(`opts.go:5545-5546`). 따라서 Go 제로값 `false` 가 그대로 유효 기본값입니다.
|
||||
|
||||
**(3) 검사 자체가 단락(short-circuit)됩니다.** `server/websocket.go:1034-1041`:
|
||||
|
||||
```go
|
||||
func (w *srvWebsocket) checkOrigin(r *http.Request) error {
|
||||
checkSame := w.sameOrigin
|
||||
listEmpty := len(w.allowedOrigins) == 0
|
||||
if !checkSame && listEmpty {
|
||||
return nil // ← 우리 설정이 여기서 끝납니다
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
우리 `websocket { port: 8080, no_tls: true }` 는 `sameOrigin=false`, `allowedOrigins` 비어 있음 → 첫 조건에서 `nil` 반환. `Origin` 헤더를 읽지도 않습니다. **`http://localhost:3000` 에서 뜬 React 대시보드가 `ws://100.x.y.z:8080` 로 붙는 시나리오는 아무 변경 없이 성공합니다.**
|
||||
|
||||
챌린지가 인용한 403 경로는 실재합니다(`websocket.go:868-870`, `StatusForbidden`, `"origin not allowed: %v"`). 다만 그 문은 `checkSame || !listEmpty` 일 때만 열립니다. 즉 **문은 있으나 우리 설정에서는 그 앞까지 가지 않습니다.**
|
||||
|
||||
### A-3. 처방 2 를 따르면 브로커가 죽는다 — 실측
|
||||
|
||||
`allowed_origins: ["*"]` 는 단지 불필요한 게 아니라 **기동 차단** 설정입니다. `server/websocket.go:1136-1150` (`validateWebsocketOptions`):
|
||||
|
||||
```go
|
||||
for _, ao := range wo.AllowedOrigins {
|
||||
u, err := url.ParseRequestURI(ao)
|
||||
if err != nil { return fmt.Errorf("unable to parse allowed origin: %v", err) }
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("unable to parse allowed origin %q: allowed origins must be "+
|
||||
"absolute URLs with http or https scheme", ao)
|
||||
}
|
||||
if u.Host == _EMPTY_ { ... }
|
||||
```
|
||||
|
||||
`"*"` 는 scheme 이 비어 있으므로 두 번째 분기에서 에러. 옵션 검증 실패는 기동 실패입니다. 게다가 논리적으로도 역효과입니다 — `AllowedOrigins` 를 비우지 않는 순간 `listEmpty` 가 `false` 가 되어 **원점 검사가 켜집니다**. "모두 허용"을 의도한 설정이 "검사 활성화"를 유발하는 구조입니다.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 이 항목은 챌린지의 처방을 그대로 구현했다면 **프로덕션 브로커가 기동조차 못 했을** 사안입니다. 리뷰를 지시가 아니라 가설로 취급하고 측정한 결과이며, `MULTI_AGENT_RULES.md` §1 의 '[REBUT:]' 절차에 해당합니다.
|
||||
|
||||
### A-4. 그럼에도 챌린지가 옳게 짚은 것
|
||||
|
||||
1. **Mixed Content 는 100% 실재하는 제약**입니다. NATS 설정과 무관하게, `https://` 로 서빙된 페이지는 `ws://` 연결을 브라우저가 차단합니다. 홈랩에서 대시보드를 HTTPS 로 올리는 순간 8080 평문 WS 는 못 씁니다. §3.4 트러블슈팅에 반영합니다.
|
||||
2. **운영자가 반드시 궁금해할 지점을 정확히 지목**했습니다. Rev.1 의 `websocket { port: 8080, no_tls: true }` 는 원점 정책에 대해 **아무 말도 하지 않았고**, 그래서 리뷰어가 정반대로 추정했습니다. 설정 파일이 침묵하면 독자가 최악을 가정한다는 증거입니다 — 주석으로 사실을 명문화합니다(§3.1).
|
||||
3. **보안 방향은 오히려 반대로 열려 있습니다.** 기본값이 관대하므로, 8080 을 tailnet 밖으로 내보내는 순간 임의 웹 페이지가 핸드셰이크를 시도할 수 있습니다(인증은 별도로 막지만). 완화가 아니라 **강화** 처방(`allowed_origins` 에 실제 대시보드 URL)이 필요하며, 이를 주석 템플릿으로 제공하고 D-30 가드로 `"*"` 회귀를 봉인합니다.
|
||||
|
||||
### A-5. 신규 발견 N-7 — 8080 은 MQTT-over-WebSocket 도 서빙한다 (열린 질문 해소)
|
||||
|
||||
챌린지의 주제(Plane B 브라우저 대시보드)를 측정하다 확인한 사실입니다.
|
||||
|
||||
```go
|
||||
// server/websocket.go:820-833
|
||||
if r.URL != nil {
|
||||
ep := r.URL.EscapedPath()
|
||||
if strings.HasSuffix(ep, leafNodeWSPath) { kind = LEAF }
|
||||
else if strings.HasSuffix(ep, mqttWSPath) { kind = MQTT } // mqttWSPath = "/mqtt"
|
||||
}
|
||||
// Reject MQTT-over-WebSocket upgrades unless MQTT is enabled.
|
||||
if kind == MQTT && opts.MQTT.Port == 0 { ... 404 ... }
|
||||
|
||||
// server/websocket.go:1333-1335
|
||||
case MQTT:
|
||||
s.createMQTTClient(res.conn, res.ws) // ← 1883 리스너와 동일 함수
|
||||
```
|
||||
|
||||
- `mqttWSPath = "/mqtt"` (`server/mqtt.go:193`).
|
||||
- 게이트는 `opts.MQTT.Port != 0` 뿐이며, 우리 설정은 `mqtt { port: 1883 }` 이므로 **이미 충족**입니다.
|
||||
- 생성 함수가 네이티브 1883 리스너와 **동일한 `createMQTTClient`** (`mqtt.go:552` vs `websocket.go:1335`) 이므로, 이 연결은 트랜스포트만 WebSocket 인 **완전한 MQTT 클라이언트**입니다.
|
||||
|
||||
**이것이 왜 중요한가**: Rev.2(`b11d499d`) 이래 남아 있던 열린 질문 — *"대시보드를 MQTT 로 붙일 것인가, 아니면 N-1(retained 는 MQTT 전용) 제약을 받아들이고 JetStream 리플레이 스트림을 만들 것인가"* — 가 **제3의 답으로 해소**됩니다.
|
||||
|
||||
> 브라우저 대시보드가 **MQTT.js 로 `ws://mam-hub:8080/mqtt` 에 접속하면**, 그것은 MQTT 클라이언트이므로 `mqttSendRetainedMsgsToNewSubs` 경로를 그대로 타고 **retained 종료 이벤트를 받습니다**. 별도 리플레이 스트림도, 디스크 관리 부담도 필요 없습니다.
|
||||
|
||||
반면 같은 8080 포트라도 **NATS 네이티브 WebSocket**(경로 없음 또는 `/`)으로 붙으면 N-1 이 그대로 적용되어 종료 이벤트를 못 받습니다. **같은 포트, 다른 경로, 다른 결과** — 이 함정은 반드시 문서화되어야 합니다.
|
||||
|
||||
부수 효과로 노출 모델 서술도 정정이 필요합니다: 8080 은 "Plane B 전용"이 아니라 **MQTT 프로토콜 표면을 함께 노출**합니다(인증은 계정 설정이 동일하게 강제).
|
||||
|
||||
---
|
||||
|
||||
## B. Rev.1 → Rev.2 변경 요약
|
||||
|
||||
| # | 변경 | 출처 |
|
||||
|---|---|---|
|
||||
| C-1 | `docker/nats.conf` `websocket {}` 블록에 **원점 정책 사실 주석 + `allowed_origins` 강화 템플릿(주석)** 추가. `same_origin: false` 는 **활성 라인으로 넣지 않음** | 챌린지 §2.3-1 변환 수용 |
|
||||
| C-2 | `docker/nats.conf` `websocket {}` 에 **`/mqtt` 경로 = MQTT-over-WS** 사실 주석 추가 | N-7 |
|
||||
| C-3 | `docker/README.md` 트러블슈팅에 **WS 403(정확한 발동 조건)** · **Mixed Content** · **`/mqtt` vs `/` 경로 차이** 3행 추가 | 챌린지 §2.3-2 + N-7 |
|
||||
| C-4 | `docker/README.md` 6절(클라이언트 연결)에 **브라우저 대시보드 접속 레시피** 신설 | N-7 |
|
||||
| C-5 | 신규 가드 **D-30**(WebSocket 원점 정책이 기동 가능한 형태인지) 추가 → 305 → **306** | A-3 |
|
||||
| C-6 | 문서 동기화 작업에 **T-5** 신설: `PRIVATE_SERVER.md` §5.1/§5.2 의 '두 소비 평면' 서술에 세 번째 경로(MQTT-over-WS) 반영 | N-7 |
|
||||
| C-7 | 실측 원장에 **M-19 ~ M-24** 추가 | A-2 / A-3 / N-7 |
|
||||
| C-8 | 열린 질문에서 'Rev.2 이월 질문' **삭제(해소됨)**, 대신 Q-5(대시보드 프로토콜 선택 권고) 로 대체 | N-7 |
|
||||
|
||||
Rev.1 의 §2 설계 결정 D-1 ~ D-8, §3.2 compose, §3.3 `.env.example`, §4 가드 D-22 ~ D-29, §5 T-1 ~ T-4, §7 발견 N-2 ~ N-6 은 **리뷰에서 전부 승인**되었으며 변경 없이 유지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 — 이 계획이 무엇을 확정하는가
|
||||
|
||||
`PRIVATE_SERVER.md` §9 는 지금까지 **문서 안의 코드 펜스**로만 존재했습니다. 펜스는 복사-붙여넣기 대상이지 배포 자산이 아니므로,
|
||||
|
||||
1. 서버에 실제로 올라간 설정이 문서와 갈라져도 아무도 알 수 없고,
|
||||
2. `test_deploy_freshness.py` 의 D-15 ~ D-19 가드는 **문서만** 검사하므로 실제 배포물의 회귀를 잡지 못하며,
|
||||
3. 시크릿을 어디에 두는지가 규약이 아니라 관습으로 남습니다.
|
||||
|
||||
본 계획은 §9 의 펜스를 `docker/` 하위의 **정본(canonical) 파일**로 승격시키고, 문서↔파일 드리프트를 9종의 신규 가드(D-22 ~ D-30)로 봉인합니다.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 현재 저장소에는 **0 바이트짜리 `docker/docker-compose.yaml`** 이 untracked 상태로 존재합니다(`git status` = `?? docker/`). 신규 가드 D-22 는 이 상태에서 **즉시 FAIL** 하도록 설계되어 있습니다 — 즉 가드가 공허하게 통과하지 않음이 착수 시점에 자동으로 증명됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 실측 기반 (Measurement Ledger)
|
||||
|
||||
| # | 검증 항목 | 방법 | 실측 결과 |
|
||||
|---|---|---|---|
|
||||
| M-1 | 테스트 베이스라인 | `pytest tests/ -q --collect-only` | **297 collected** |
|
||||
| M-2 | `nats:2.12-alpine` 태그 실재 | Docker Hub API `library/nats/tags?name=2.12` | 존재. 최신 패치 **2.12.15** (2026-08-12) |
|
||||
| M-3 | alpine 이미지 베이스 | `nats-docker/main/2.12.x/alpine3.22/Dockerfile` | `FROM alpine:3.22` → **busybox `wget` 내장** |
|
||||
| M-4 | 엔트리포인트 인자 처리 | 동 디렉터리 `docker-entrypoint.sh` | `[ "${1#-}" != "$1" ] && set -- nats-server "$@"` → `command: ["-c", …]` **동작** |
|
||||
| M-5 | 이미지 HEALTHCHECK 유무 | 동 Dockerfile | **없음** → compose 가 반드시 정의 |
|
||||
| M-6 | 미해결 `$VAR` 동작 | `conf/parse.go:390` | 파싱 에러 = **fail-closed** |
|
||||
| M-7 | 환경변수 값 재파싱 | `conf/parse.go` `lookupVariable` → `parseEnv(...)` | 환경변수 값이 **NATS 렉서로 재파싱** (N-5) |
|
||||
| M-8 | 비인용 문자열 종결자 | `conf/lex.go:958-960` | NL, EOF, `;`, `,`, `]`, `}`, 공백. `=` `+` `/` 는 **비종결자** |
|
||||
| M-9 | `mqtt {}` 유효 키 | `server/opts.go` `parseMQTT` | `port`/`ack_wait`/`max_ack_pending` **전부 유효** |
|
||||
| M-10 | `max_ack_pending` 상한 | `opts.go:5673-5679` | `[0..65535]`. **1024 유효** |
|
||||
| M-11 | `jetstream {}` 유효 키 | `opts.go` `parseJetStream` | `store_dir`, `max_file`, `max_mem` **전부 유효** |
|
||||
| M-12 | 크기 접미사 대소문자 | `opts.go:2507` `suffixMap` | `{"K","M","G","T"}` **대문자 전용** (N-6) |
|
||||
| M-13 | `websocket {}` 유효 키 | `opts.go` `parseWebsocket` | `port`, `no_tls`, `same_origin`, `allowed_origins` 등 유효 |
|
||||
| M-14 | `.gitignore` 거동 | `git check-ignore -v` | `docker/.env` **ignored**(`:21`), `docker/.env.example` **tracked**(`:23`) |
|
||||
| M-15 | 비인용 `${VAR:?msg}` YAML | PyYAML 6.0.3 파싱 | 평문 스칼라 → **문서 원문 그대로 파일화 가능** |
|
||||
| M-16 | PyYAML 선언 여부 | `requirements.txt`=1행, `tests/test_sanity.py:3`=`import yaml` | **미선언 하드 의존** (N-2) |
|
||||
| M-17 | 설치 스크립트 배포 범위 | `deploy/install.sh:365-374` | `docker/`·`PRIVATE_SERVER.md` **미배포** |
|
||||
| M-18 | `DEFAULT_TOPIC_ROOT` | `mqtt_common.py:119` | `"python/mqtt/jobs"` |
|
||||
| **M-19** | **`SameOrigin` 기본값** | `opts.go` 전역 grep `SameOrigin = true` / `SameOrigin:` | **0건** → Go 제로값 `false`. 주석(`opts.go:676-677`)도 "empty and SameOrigin is false → any origin is accepted" 명시 |
|
||||
| **M-20** | **`checkOrigin` 단락 조건** | `websocket.go:1034-1041` | `!checkSame && listEmpty` → **즉시 `nil`**. `Origin` 헤더를 읽지 않음 |
|
||||
| **M-21** | **403 발동 지점** | `websocket.go:868-870` | `StatusForbidden "origin not allowed"` — `checkOrigin` 이 에러일 때만 |
|
||||
| **M-22** | **`allowed_origins: ["*"]`** | `websocket.go:1136-1150` `validateWebsocketOptions` | scheme 이 http/https 가 아니라며 **옵션 검증 실패 → 기동 실패** |
|
||||
| **M-23** | **`no_tls` 필요성** | `websocket.go:1132-1134` | `TLSConfig == nil && !NoTLS` → `websocket requires TLS configuration`. 현 설정의 `no_tls: true` 는 **필수** |
|
||||
| **M-24** | **MQTT-over-WebSocket** | `mqtt.go:193` `mqttWSPath="/mqtt"`; `websocket.go:824,832,1334-1335` | 8080 의 `/mqtt` 경로가 `createMQTTClient` 로 분기. 게이트는 `MQTT.Port != 0` 뿐 → **이미 활성** (N-7) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 결정 (Design Decisions)
|
||||
|
||||
### D-1. 파일명은 `docker/docker-compose.yaml` + 문서 참조 정정
|
||||
브리프는 `.yaml`, `PRIVATE_SERVER.md` §9.2 제목은 `.yml` 을 씁니다. Compose 는 둘 다 인식하므로 기능 차는 없습니다. **브리프를 정본으로 채택**하고 문서 참조를 정정합니다(T-3). 두 곳이 다른 채로 남으면 D-23 doc↔file 가드가 무엇을 비교하는지 모호해집니다.
|
||||
|
||||
### D-2. 계정 사용자명은 `mam_agent` / `mam_observer` 유지
|
||||
브리프의 `MAM with mam/observer` 축약은 **계정 구조**를 가리킨 것으로 읽습니다. 식별자를 바꾸면 `PRIVATE_SERVER.md` §6 (`MQTT_USERNAME=mam_agent`) 과 §9.4 R-9 플레이북이 조용히 깨집니다.
|
||||
|
||||
### D-3. `version: '3.8'` 제거
|
||||
Compose V2 는 매 `up` 마다 `the attribute 'version' is obsolete` 경고를 냅니다. 프로덕션 자산이 상시 경고를 뿜으면 운영자가 경고를 무시하는 습관을 들입니다. 기능 영향 0.
|
||||
|
||||
### D-4. `.env.example` 의 시크릿은 **빈 값**으로 출하 (fail-closed 의 핵심)
|
||||
|
||||
| 방식 | `cp .env.example .env` 후 결과 |
|
||||
|---|---|
|
||||
| `MAM_BROKER_PASS=changeme` | 브로커 **정상 기동**. 전 세계가 아는 암호로 프로덕션 가동 = **fail-open** |
|
||||
| `MAM_BROKER_PASS=` (빈 값) ✅ | `${VAR:?…}` 는 콜론 형태라 **빈 값에서도 중단** → 컨테이너 생성 전 비영점 종료 |
|
||||
|
||||
방어는 이중입니다: Compose 보간 실패(1차) → 그래도 떴다면 `nats.conf` 미해결 `$VAR` 파싱 에러(M-6, 2차).
|
||||
|
||||
### D-5. `nats.conf` 는 시크릿을 한 글자도 담지 않는다
|
||||
모든 `password:` 는 `$VAR` 참조. 따라서 `docker/nats.conf` 는 커밋 가능하고, 시크릿은 gitignore 된 `docker/.env` 에만 존재합니다(M-14). D-25(e) 가 봉인합니다.
|
||||
|
||||
### D-6. 암호 생성기는 `openssl rand -base64 32` 로 고정하고 이유를 문서화
|
||||
파서 제약입니다(M-7/M-8). 암호에 `공백 ; , ] } # ' " $` 가 들어가면 설정이 깨지거나 조용히 다른 값이 됩니다. base64 알파벳(`A-Za-z0-9+/=`)은 이 집합과 교집합이 0입니다.
|
||||
|
||||
### D-7. healthcheck 는 `wget` 유지 — 이미지 계열과 **커플링**해서 봉인
|
||||
`wget` 은 alpine 베이스에만 있습니다(M-3). D-28 은 healthcheck 존재와 이미지 alpine 여부를 **한 테스트 안에서** 단언합니다. 분리하면 이미지만 바꾸는 커밋이 통과합니다.
|
||||
|
||||
### D-8. `docker/` 는 하위 워크스페이스로 배포하지 않는다
|
||||
`install.sh` 는 `PRIVATE_SERVER.md` 조차 배포하지 않습니다(M-17). `docker/` 는 **이 저장소가 운영하는 서버**의 자산이므로 동일하게 저장소 전용으로 둡니다. `install.sh` 를 건드리지 않는 것이 명시적 결정입니다(Q-3).
|
||||
|
||||
### D-9 (신규). WebSocket 원점 정책은 **활성 설정이 아니라 주석으로** 다룬다
|
||||
|
||||
세 가지 선택지를 검토했습니다.
|
||||
|
||||
| 선택 | 결과 |
|
||||
|---|---|
|
||||
| `same_origin: false` 활성 추가 (챌린지 처방 1) | 동작상 **no-op**(M-19). 그러나 설정 파일이 "이걸 꺼야 브라우저가 붙는다"는 **거짓 서사**를 후임자에게 전달 |
|
||||
| `allowed_origins: ["*"]` (챌린지 처방 2) | 🔴 **기동 실패**(M-22) |
|
||||
| **주석으로 기본 동작을 명문화 + 강화 템플릿을 주석 제공** ✅ | 동작 불변, 리뷰어가 실제로 겪은 정보 공백을 메움, 8080 을 tailnet 밖으로 낼 때 필요한 **강화** 경로를 즉시 제공 |
|
||||
|
||||
세 번째를 채택합니다. 리뷰어의 관찰(설정이 원점 정책에 침묵한다)은 타당했고, 처방(끄기)만 방향이 반대였습니다. 침묵을 메우되 사실대로 메웁니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 산출물 명세 (Creator 구현 사양)
|
||||
|
||||
### 3.1 `docker/nats.conf`
|
||||
|
||||
```conf
|
||||
# ==============================================================================
|
||||
# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
#
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.1
|
||||
# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose
|
||||
# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다.
|
||||
#
|
||||
# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다.
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오.
|
||||
# ==============================================================================
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T)
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose)
|
||||
|
||||
mqtt {
|
||||
port: 1883
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535)
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패
|
||||
|
||||
# ── 원점(Origin) 정책 ────────────────────────────────────────────────
|
||||
# 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고
|
||||
# allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고
|
||||
# 즉시 nil 을 반환합니다 (server/websocket.go:1039).
|
||||
# → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다.
|
||||
# → `same_origin: false` 를 적는 것은 no-op 입니다.
|
||||
#
|
||||
# 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오.
|
||||
# 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'.
|
||||
# "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다
|
||||
# (websocket.go:1142 "must be absolute URLs with http or https scheme").
|
||||
# allowed_origins: ["https://dashboard.example", "http://localhost:3000"]
|
||||
|
||||
# ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ───────────────────────
|
||||
# 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193,
|
||||
# websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수).
|
||||
# ws://<host>:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다.
|
||||
# ws://<host>:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1).
|
||||
# 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다.
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 ────────────────────────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME)
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치
|
||||
{ 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
|
||||
```
|
||||
|
||||
**계약 값** (가드 검사 대상): `store_dir` = `/data` · `mqtt.port` = `1883` · `websocket.port` = `8080` · `http_port` = `8222` · 계정 `MAM`/`HOME`/`SYS` · 사용자 `mam_agent`/`mam_observer`/`home`/`sys` · subject 접두 `python.mqtt.jobs` · 모든 `password:` 는 `$` 시작 · 활성 `allowed_origins` 에 `"*"` 없음(D-30).
|
||||
|
||||
> [!NOTE]
|
||||
> `accounts {}` 를 정의하고 `no_auth_user` 를 두지 않았으므로 **익명 접속은 MQTT·NATS·WebSocket 전 경로에서 거부**됩니다. 이는 §9.4 R-3(노출 면적 0)과 독립된 두 번째 방어선이며, N-7 로 드러난 `/mqtt` 표면에도 동일하게 적용됩니다.
|
||||
|
||||
### 3.2 `docker/docker-compose.yaml`
|
||||
|
||||
```yaml
|
||||
# ==============================================================================
|
||||
# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.2
|
||||
#
|
||||
# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d
|
||||
# ==============================================================================
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
environment:
|
||||
# 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.env}
|
||||
ports:
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라
|
||||
# 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback.
|
||||
- "${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" # 무인증 모니터링 — loopback 고정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data # nats.conf 의 store_dir 와 일치
|
||||
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:
|
||||
```
|
||||
|
||||
**주의 사항**
|
||||
- `8222` 만 바인드 주소가 하드코딩입니다. 나머지 3개는 `${*_BIND:-127.0.0.1}` 로 tailnet IP 주입을 허용하되 기본값이 loopback 입니다. D-24 가 이 비대칭을 검사합니다.
|
||||
- `./nats.conf` 는 compose 프로젝트 디렉터리 기준 상대경로이므로 `docker/` 안에서 실행해야 합니다.
|
||||
- 비인용 `${VAR:?msg}` 는 PyYAML 로 평문 스칼라 파싱됨을 실측(M-15). 인용부호 추가 불필요.
|
||||
|
||||
### 3.3 `docker/.env.example`
|
||||
|
||||
```bash
|
||||
# ==============================================================================
|
||||
# docker/.env.example — 원격 브로커 서버 측 환경변수 템플릿
|
||||
#
|
||||
# 이 파일은 git 에 커밋됩니다 (.gitignore:23 의 `!.env.example`).
|
||||
# 복사본 docker/.env 는 git 에서 제외됩니다 (.gitignore:21 의 `.env`).
|
||||
#
|
||||
# cd docker && cp .env.example .env && chmod 600 .env
|
||||
#
|
||||
# ⚠ 아래 시크릿 4종은 의도적으로 **빈 값**입니다. 채우지 않고 그대로 복사하면
|
||||
# `docker compose up` 이 컨테이너를 만들기 전에 실패합니다 (fail-closed).
|
||||
# 플레이스홀더 문자열을 넣지 마십시오 — '알려진 암호로 가동'이 최악입니다.
|
||||
# ==============================================================================
|
||||
|
||||
# ── 시크릿 (필수) ────────────────────────────────────────────────────────
|
||||
# 생성: openssl rand -base64 32
|
||||
#
|
||||
# ⚠ 반드시 위 명령을 사용하십시오. NATS 는 환경변수 값을 설정 렉서로 재파싱하므로
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 포함되면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# base64 알파벳(A-Za-z0-9+/=)은 이 문자들과 교집합이 없어 안전합니다.
|
||||
|
||||
# MAM 에이전트 발행/구독 계정 (.mam.env 의 MQTT_PASSWORD 와 동일 값)
|
||||
MAM_BROKER_PASS=
|
||||
|
||||
# 관측 전용 계정 (구독 allow: python.mqtt.jobs.>, 발행 전면 deny)
|
||||
MAM_OBSERVER_PASS=
|
||||
|
||||
# MAM 과 무관한 홈랩 서비스용 별도 계정
|
||||
HOME_BROKER_PASS=
|
||||
|
||||
# 시스템 계정 ($SYS). 운영 이벤트/모니터링 전용
|
||||
SYS_BROKER_PASS=
|
||||
|
||||
# ── 리스너 바인드 주소 (선택 — 미설정 시 전부 127.0.0.1) ──────────────────
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 아래 값이 실질적인 노출 통제입니다.
|
||||
# 모델 T(Tailscale) 권장 설정: MQTT_BIND=$(tailscale ip -4)
|
||||
#
|
||||
# MQTT 1883 리스너 바인드 주소
|
||||
# MQTT_BIND=127.0.0.1
|
||||
#
|
||||
# NATS 4222 네이티브 리스너 바인드 주소
|
||||
# NATS_BIND=127.0.0.1
|
||||
#
|
||||
# WebSocket 8080 바인드 주소.
|
||||
# 참고: 이 포트는 NATS WebSocket 과 MQTT-over-WebSocket(/mqtt)을 함께 서빙합니다.
|
||||
# WS_BIND=127.0.0.1
|
||||
#
|
||||
# HTTP 모니터 8222 는 무인증이므로 바인드 주소를 변수화하지 않습니다 (loopback 고정).
|
||||
```
|
||||
|
||||
**설계 포인트**: 시크릿 4종은 **주석 해제 없이 빈 값으로 활성**, 바인드 주소 3종은 **주석 처리**. 이 비대칭이 "시크릿은 반드시 채워야 하고, 바인드는 안 채워도 안전한 기본값"이라는 의도를 파일 형태로 표현합니다.
|
||||
|
||||
### 3.4 `docker/README.md`
|
||||
|
||||
| 절 | 내용 | 정본 |
|
||||
|---|---|---|
|
||||
| 1. 무엇인가 | 이 디렉터리가 MAM 관측 백플레인 브로커의 정본임. `PRIVATE_SERVER.md` §9 링크 | §9 |
|
||||
| 2. 사전 요구 | Docker Engine + Compose V2, (모델 T) Tailscale, 개방 포트 없음 | §9.3 |
|
||||
| 3. 5분 배포 | `docker/` 복사 → `cp .env.example .env` → `openssl rand -base64 32` ×4 → `chmod 600 .env` → `docker compose up -d` → `docker compose ps` **healthy** | §9.2 |
|
||||
| 4. 네트워크 잠금 | UFW 규칙 전문 + **published 포트가 UFW 를 우회**한다는 경고를 최상단에 | §9.3 |
|
||||
| 5. 노출 검증 | 외부 망에서 `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` → 전부 closed/filtered (R-3) | §9.4 |
|
||||
| 6. 클라이언트 연결 | (a) MAM 저장소 `.mam.env` 기입 (b) **브라우저 대시보드 레시피** (아래) | §6 + N-7 |
|
||||
| 7. 검증 플레이북 | R-1 ~ R-10 표 + 지연 측정 스니펫 링크 | §9.4 |
|
||||
| 8. 운영 | 로그 로테이션(내장), JetStream 볼륨 백업, `docker compose pull && up -d`, `/varz`·`/jsz` 는 SSH 터널 경유 | §9 P6 |
|
||||
| 9. 트러블슈팅 | 아래 표 | 신규 |
|
||||
|
||||
**6절 (b) 브라우저 대시보드 접속 레시피** — N-7 반영, 필수 신설:
|
||||
|
||||
```js
|
||||
// MQTT.js — retained 종료 이벤트까지 받는 경로 (권장)
|
||||
const client = mqtt.connect("ws://mam-hub.tailXXXX.ts.net:8080/mqtt", {
|
||||
username: "mam_observer",
|
||||
password: "<MAM_OBSERVER_PASS>",
|
||||
protocolVersion: 4, // MQTT 3.1.1
|
||||
});
|
||||
client.subscribe("python/mqtt/jobs/+/events");
|
||||
// 이 클라이언트는 서버에서 완전한 MQTT 클라이언트로 취급되므로
|
||||
// 잡이 끝난 뒤에 접속해도 retained 최종 이벤트를 즉시 수신합니다.
|
||||
|
||||
// nats.ws — 경로 없이 붙으면 NATS 네이티브. 라이브 스트림만 수신하며
|
||||
// 이미 끝난 잡의 종료 이벤트는 받지 못합니다 (N-1).
|
||||
```
|
||||
|
||||
**9절 트러블슈팅 표(필수 항목)**
|
||||
|
||||
| 증상 | 원인 | 조치 |
|
||||
|---|---|---|
|
||||
| `required variable MAM_BROKER_PASS is missing` | `.env` 미생성 또는 빈 값 | 의도된 fail-closed. 시크릿을 채울 것 |
|
||||
| `variable reference for 'MAM_BROKER_PASS' … can not be found` | compose 통과했으나 컨테이너에 변수 미주입 | `environment:` 블록 누락 확인 |
|
||||
| 컨테이너는 뜨는데 계속 `unhealthy` | 이미지를 `latest`/`scratch`/non-alpine 로 변경 → `wget` 부재 | `nats:2.12-alpine` 로 복귀 |
|
||||
| 설정이 알 수 없는 값으로 해석됨 | 암호에 렉서 종결자 포함 | `openssl rand -base64 32` 로 재발급 |
|
||||
| `max_file` 에러 | `10g` 등 소문자 접미사 | 대문자 `10G` |
|
||||
| 원격에서 접속 불가 | 바인드가 loopback 기본값 | `.env` 에 `MQTT_BIND=$(tailscale ip -4)` |
|
||||
| `JetStream not enabled for account` | 계정에 `jetstream: enabled` 누락 | `MAM` 계정 블록 확인 |
|
||||
| **WebSocket `403 origin not allowed`** | **기본 설정에서는 발생하지 않습니다.** `allowed_origins` 를 채웠거나 `same_origin: true` 를 켠 경우에만 발동 | 해당 설정을 지우거나, 대시보드 URL을 `allowed_origins` 에 추가 |
|
||||
| **서버가 `allowed origins must be absolute URLs…` 로 기동 실패** | `allowed_origins` 에 `"*"` 또는 scheme 없는 값 | `["https://dashboard.example"]` 처럼 절대 URL 로 |
|
||||
| **브라우저 콘솔의 Mixed Content 차단** | `https://` 페이지에서 `ws://` 연결 시도 | 대시보드를 tailnet 내부 `http://` 로 서빙하거나, 리버스 프록시로 `wss://` 종단 제공 |
|
||||
| **대시보드가 종료 이벤트를 못 받음** | 8080 에 **경로 없이**(NATS 네이티브) 접속함. retained 는 MQTT 전용 (N-1) | `ws://host:8080/**mqtt**` 로 MQTT.js 접속 (N-7) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 신규 회귀 가드 D-22 ~ D-30
|
||||
|
||||
`tests/test_deploy_freshness.py` 말미에 추가합니다. 기존 D-11 ~ D-21 스타일(모듈 상단 상수, 펜스 스코핑, 공허 통과 방지 단언)을 따릅니다.
|
||||
|
||||
### 4.1 공통 헬퍼
|
||||
|
||||
```python
|
||||
DOCKER_DIR = os.path.join(REPO_ROOT, "docker")
|
||||
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
|
||||
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
|
||||
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
|
||||
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
|
||||
|
||||
SECRET_VARS = {"MAM_BROKER_PASS", "MAM_OBSERVER_PASS",
|
||||
"HOME_BROKER_PASS", "SYS_BROKER_PASS"}
|
||||
|
||||
|
||||
def _load_compose():
|
||||
"""compose 파일을 dict 로. 파일이 비었거나 nats 서비스가 없으면 즉시 실패."""
|
||||
import yaml
|
||||
with open(COMPOSE_PATH, encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert isinstance(doc, dict) and doc.get("services"), (
|
||||
"docker/docker-compose.yaml is empty or has no services: — the docker/ "
|
||||
"assets were never populated")
|
||||
svc = doc["services"].get("nats")
|
||||
assert svc, "compose file defines no 'nats' service"
|
||||
return doc, svc
|
||||
|
||||
|
||||
def _active_conf_lines():
|
||||
"""nats.conf 에서 주석을 제외한 '활성' 라인만. 주석 템플릿을 오탐하지 않기 위함."""
|
||||
with open(NATS_CONF_PATH, encoding="utf-8") as f:
|
||||
lines = [ln.split("#", 1)[0].rstrip() for ln in f]
|
||||
return [ln for ln in lines if ln.strip()]
|
||||
```
|
||||
|
||||
> `_active_conf_lines()` 는 D-30 의 정확도를 위한 것입니다. §3.1 은 `allowed_origins` 를 **주석 템플릿**으로 제공하므로, 주석을 그대로 스캔하면 가드가 자기 문서를 오탐합니다.
|
||||
|
||||
### 4.2 가드 명세
|
||||
|
||||
| ID | 이름 | 단언 | 공허 통과 방지 | 잡아내는 회귀 |
|
||||
|---|---|---|---|---|
|
||||
| **D-22** | `docker_assets_exist_and_are_populated` | 4개 파일 존재 + 크기 > 0, compose 가 `nats` 서비스를 갖는 dict 로 파싱 | `_load_compose()` 가 `services` 비면 실패 | **현재의 0바이트 compose**, 자산 삭제 |
|
||||
| **D-23** | `compose_image_matches_doc_and_is_alpine` | compose 의 `nats:<tag>` == `PRIVATE_SERVER.md` 펜스에서 추출한 태그, `tag != "latest"`, `"alpine" in tag` | 양쪽에서 태그 추출 실패 시 실패 | 한쪽만 업그레이드하는 드리프트, scratch 회귀 |
|
||||
| **D-24** | `compose_port_exposure_contract` | 컨테이너 포트 집합 == `{1883,4222,8222,8080}`; 모든 게시가 3필드 → **bare `"1883:1883"` 금지**; `8222` 는 정확히 `127.0.0.1:8222:8222` | `ports` 비면 실패 | 바인드 누락으로 인터넷 노출, 포트 오타 |
|
||||
| **D-25** | `secrets_are_fail_closed` | (a) `nats.conf` 의 모든 `$VAR` ⊆ compose `environment:` 키 (b) 값이 전부 `:?` 형태 (c) `SECRET_VARS` 전부 `.env.example` 에 존재 (d) `.env.example` 의 시크릿 4종 값이 **빈 문자열** (e) `nats.conf` 의 모든 `password:` 값이 `$` 시작 | `$VAR` 0건이면 실패 | 플레이스홀더 암호, 평문 시크릿, `:?`→`:-` 완화 |
|
||||
| **D-26** | `nats_conf_jetstream_and_mqtt_contract` | `store_dir` 절대경로 + compose 볼륨 타깃과 **동일**; `max_file`/`max_mem` 이 `^\d+[KMGT]$`; `mqtt {` + `port: 1883`; `MAM` 계정 `jetstream: enabled` | 추출 실패 시 실패 | `store_dir`↔볼륨 불일치(데이터 유실), 소문자 접미사, 계정 JS 누락 |
|
||||
| **D-27** | `observer_permissions_match_topic_root` | subject 리터럴이 `mqtt_common.DEFAULT_TOPIC_ROOT.replace("/",".")` 로 시작; `mam_observer` 에 `publish` `deny` 존재 | 리터럴 0건이면 실패 | M3 지문 토픽 전환 시 관측자 권한 고아화 |
|
||||
| **D-28** | `healthcheck_contract_and_image_coupling` | healthcheck 존재 + `/healthz` + `127.0.0.1:8222` + `wget`; **동일 테스트에서** 이미지가 alpine 계열임을 단언 | healthcheck 키 없으면 실패 | healthcheck 삭제, wget 없는 이미지 |
|
||||
| **D-29** | `env_secrets_never_tracked` | `git check-ignore docker/.env` rc == 0; `.env.example` 은 not-ignored; `git ls-files docker/.env` 빈 출력 | — | `.gitignore` 완화로 실제 시크릿 커밋 |
|
||||
| **D-30** 🆕 | `websocket_origin_policy_is_startable` | **활성**(비주석) 라인 기준: (a) `websocket {` 블록에 `no_tls: true` 존재(M-23); (b) `allowed_origins` 가 활성이라면 그 항목이 전부 `http://` 또는 `https://` 로 시작 — 특히 **`"*"` 금지**(M-22); (c) `nats.conf` 가 `/mqtt` 경로 주석을 포함해 N-7 지식이 유실되지 않음 | `websocket {` 블록을 못 찾으면 실패 | **챌린지 처방 2를 그대로 구현했을 때의 기동 불능**, `no_tls` 누락으로 인한 TLS 요구 에러, N-7 문서 소실 |
|
||||
|
||||
### 4.3 뮤테이션 수용 기준 (구현 완료의 정의)
|
||||
|
||||
가드는 **깨져야 할 때 깨지는 것이 증명되어야** 통과로 인정합니다. Creator 는 아래를 각각 적용 → 해당 테스트 FAIL 확인 → 원복하고, 로그를 커밋 메시지 또는 리뷰 요청에 첨부합니다.
|
||||
|
||||
| 뮤테이션 | FAIL 해야 하는 가드 |
|
||||
|---|---|
|
||||
| `docker-compose.yaml` 을 0바이트로 되돌림 | D-22 |
|
||||
| 이미지를 `nats:latest` 로 변경 | D-23, D-28 |
|
||||
| `- "1883:1883"` (바인드 주소 제거) | D-24 |
|
||||
| `8222` 를 `0.0.0.0:8222:8222` 로 변경 | D-24 |
|
||||
| `.env.example` 의 `MAM_BROKER_PASS=` → `=changeme` | D-25 |
|
||||
| compose 의 `:?` → `:-` 로 완화 | D-25 |
|
||||
| `store_dir: "/data"` → `"/var/lib/nats"` (볼륨 유지) | D-26 |
|
||||
| `subscribe.allow` 를 `"mam.>"` 로 변경 | D-27 |
|
||||
| healthcheck 블록 삭제 | D-28 |
|
||||
| `.gitignore` 에 `!docker/.env` 추가 | D-29 |
|
||||
| **`allowed_origins: ["*"]` 를 활성 라인으로 추가** | **D-30** |
|
||||
| **`websocket {}` 에서 `no_tls: true` 삭제** | **D-30** |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **D-22 는 착수 시점에 이미 FAIL 합니다** (0바이트 compose 파일 때문). 가드를 먼저 커밋하고 자산을 채우는 **테스트 우선 순서**로 진행하면, 이 가드가 공허하게 통과하지 않는다는 사실이 별도 뮤테이션 없이 자동 증명됩니다.
|
||||
|
||||
### 4.4 예상 테스트 수
|
||||
|
||||
| 시점 | 수 |
|
||||
|---|---|
|
||||
| 현재 (`3523b9b`) — 실측 | **297** |
|
||||
| D-22 ~ D-30 추가 후 | **306** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 문서 동기화 작업 (Creator 범위)
|
||||
|
||||
`docker/` 만 만들고 문서를 두면 다음 리뷰에서 "무엇이 정본인가"가 다시 논쟁이 됩니다. 아래 5건을 **같은 커밋**에 포함합니다.
|
||||
|
||||
| ID | 파일 | 작업 |
|
||||
|---|---|---|
|
||||
| **T-1** | `PRIVATE_SERVER.md` §9 서두 | "본 절의 설정은 [`docker/`](docker/) 에 정본 파일로 존재하며 아래 펜스는 그 사본입니다. 어긋나면 `tests/test_deploy_freshness.py` 의 D-22~D-30 이 실패합니다." 1문단 추가 |
|
||||
| **T-2** | `PRIVATE_SERVER.md` §9.1 / §9.2 | 펜스를 `docker/nats.conf`·`docker/docker-compose.yaml` 최종본과 **일치**시킴 (`version: '3.8'` 제거, **§3.1 의 websocket 주석 블록 포함**) |
|
||||
| **T-3** | `PRIVATE_SERVER.md` §9.2 제목 · §9.3 `.env` 블록 | `docker-compose.yml` → `docker/docker-compose.yaml`; `.env` 생성 절차를 `docker/.env.example` 복사 방식으로 교체 |
|
||||
| **T-4** | `implementation_plan.md` §5 / §8 | §5 로드맵에 **P0.5 `docker/` 자산 정본화** 삽입; §8 M2b 에서 이미 완료된 `G-D5~G-D9, G-R1, G-R2` 를 `[x]` 로 정정(N-4)하고 `docker/` 자산 + D-22~D-30 (297 → 306) 항목 신설 |
|
||||
| **T-5** 🆕 | `PRIVATE_SERVER.md` §5.1 / §5.2 | '두 개의 소비 평면' 서술에 **세 번째 경로**를 명문화: 8080 은 NATS WebSocket 과 **MQTT-over-WebSocket(`/mqtt`)** 을 함께 서빙하며, 후자만 retained 종료 이벤트를 받는다(N-1 ↔ N-7 연결). §5.2 의 브리징 문단이 현재 이 구분 없이 서술되어 있어 정정 대상 |
|
||||
|
||||
T-2 수행 시 주의: §9.1/§9.2 펜스는 기존 D-15 ~ D-19 가드의 검사 대상이기도 하므로, 편집 후 **전체 스위트를 돌려** 기존 7종 가드의 회귀 없음을 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 실행 순서 및 완료 정의
|
||||
|
||||
```
|
||||
[1] 가드 선행 커밋 ──> [2] docker/ 자산 작성 ──> [3] 문서 동기화 ──> [4] 뮤테이션 검증 ──> [5] 서버 실배포
|
||||
D-22~D-30 추가 3.1~3.4 (4개 파일) T-1~T-5 §4.3 12건 전건 R-1~R-10 (+R-11)
|
||||
D-22 FAIL 확인 306 전건 GREEN 기존 D-15~D-19 각 FAIL→원복 (별도 잡)
|
||||
회귀 없음
|
||||
```
|
||||
|
||||
**DoD (본 잡의 완료 정의)**
|
||||
1. `docker/` 4개 파일이 §3 사양대로 존재하고, `docker/.env` 는 존재하지 않는다.
|
||||
2. `pytest tests/ -q` 가 **306건 전건 통과**.
|
||||
3. §4.3 뮤테이션 12건이 각각 지정된 가드를 FAIL 시킴이 로그로 확인된다.
|
||||
4. T-1 ~ T-5 문서 동기화가 동일 커밋에 포함된다.
|
||||
5. `git status` 에 `docker/.env` 가 나타나지 않는다(D-29 로 자동 보증).
|
||||
|
||||
**게이트**: 3번(뮤테이션 전건 FAIL) 미충족 시 커밋 금지. 통과하지 않는 가드는 가드가 아니라 주석입니다.
|
||||
|
||||
**신규 원격 검증 항목** (서버 배포 잡으로 이월):
|
||||
|
||||
| ID | 검증 | 방법 | 통과 기준 |
|
||||
|---|---|---|---|
|
||||
| **R-11** | healthcheck 의 음성 대조 | 컨테이너 안에서 `wget -q -O /dev/null http://127.0.0.1:8222/healthzX` (404) | **비영점 종료** — 200 이 아닐 때 실제로 실패함을 증명 |
|
||||
| **R-12** | 교차 출처 브라우저 접속 | 다른 오리진의 페이지에서 `new WebSocket("ws://<host>:8080/")` | **핸드셰이크 성공** (A-2 의 반증 가능 형태 — 403 이 나오면 M-19/M-20 판정이 틀린 것) |
|
||||
| **R-13** | MQTT-over-WS retained | 잡 종료 **후** MQTT.js 로 `ws://<host>:8080/mqtt` 접속 → 구독 | **retained 최종 이벤트 수신** (N-7 확증. 0건이면 N-7 이 틀린 것) |
|
||||
|
||||
R-12 와 R-13 은 **의도적으로 반증 가능하게** 작성했습니다. 실측이 틀렸다면 이 두 항목에서 드러나며, 그 경우 §A-2 와 §A-5 를 폐기하고 챌린지의 처방 1을 재검토해야 합니다.
|
||||
|
||||
**본 잡 범위 밖**: 실제 서버 프로비저닝, Tailscale 가입, `.mam.env` 전환, R-1 ~ R-13 실행.
|
||||
|
||||
---
|
||||
|
||||
## 7. 발견 사항 (Findings)
|
||||
|
||||
### N-2 (P2) — `PyYAML` 이 선언되지 않은 하드 테스트 의존
|
||||
`requirements.txt` 는 `pytest>=8.0` **단 한 줄**인데 `tests/test_sanity.py:3` 은 최상단에서 `import yaml` 합니다. 현재 venv 에는 설치되어 있어 드러나지 않지만, `requirements.txt` 만으로 새 venv 를 만들면 **수집 단계에서 다수 파일이 에러**납니다. D-22 ~ D-28 이 `yaml` 의존을 더 깊게 만듭니다.
|
||||
**처방**: `requirements.txt` 에 `PyYAML>=6.0` 추가. (`pytest.importorskip` 은 부적절 — 스킵되면 배포 가드가 조용히 사라집니다.)
|
||||
|
||||
### N-3 (P2) — D-16 가드의 구멍: `nats:2.12` 가 통과한다
|
||||
`tests/test_deploy_freshness.py:387` 의 `assert "-alpine" in tag or tag.startswith("2.")` 는 `or` 때문에 `nats:2.12`(non-alpine) 나 `nats:2.12-scratch` 를 통과시킵니다. 그런데 healthcheck 는 alpine 에만 있는 `wget` 을 씁니다(M-3). 즉 D-16 은 자신이 막으려던 실패 모드의 **인접 변종을 놓칩니다**.
|
||||
**처방**: `assert "alpine" in tag` 로 단순화.
|
||||
|
||||
### N-4 (P3) — `implementation_plan.md` 체크리스트가 실제보다 뒤처짐
|
||||
§8 M2b 의 `- [ ] 신규 가드 G-D5 ~ G-D9, G-R1, G-R2 …(290 -> 297)` 이 미체크이나 실측 수집 수는 **297** 이고 D-15 ~ D-21 이 이미 존재합니다. T-4 에서 정정합니다.
|
||||
|
||||
### N-5 (P2) — NATS 는 환경변수 값을 **설정 렉서로 재파싱**한다
|
||||
`conf/parse.go` 의 `lookupVariable` 은 환경변수를 `parseEnv(fmt.Sprintf("%s=%s", pkey, vStr), p)` 로 **다시 파싱**합니다(M-7). 비인용 문자열 종결자는 NL·EOF·`;`·`,`·`]`·`}`·공백(M-8)이며 `#`·`$`·따옴표도 위험합니다. 문서는 `openssl rand -base64 32` 를 쓰면서 **왜 그래야 하는지**를 적지 않았습니다. §3.1/§3.3 주석과 README 트러블슈팅에 명문화합니다.
|
||||
|
||||
### N-6 (P3) — 크기 접미사는 대문자 전용
|
||||
`opts.go:2507` `suffixMap` 은 `{"K","M","G","T"}` 뿐. `max_file: 10g` 는 기동 실패. D-26 이 `^\d+[KMGT]$` 로 봉인합니다.
|
||||
|
||||
### N-7 (P1, 신규) — 8080 은 `/mqtt` 경로로 MQTT-over-WebSocket 을 서빙한다
|
||||
§A-5 참조. `mqttWSPath = "/mqtt"`(`mqtt.go:193`)이며 `websocket.go:1334-1335` 가 네이티브 1883 리스너와 **동일한 `createMQTTClient`** 로 분기합니다. 게이트는 `opts.MQTT.Port != 0` 뿐이라 우리 설정에서 **이미 활성**입니다. 세 가지 귀결:
|
||||
1. 브라우저 대시보드가 MQTT.js 로 `/mqtt` 에 붙으면 **retained 종료 이벤트를 받습니다** → Rev.2 이래의 열린 질문 해소, JetStream 리플레이 스트림 불필요.
|
||||
2. 같은 포트에 경로만 다르게 붙으면(NATS 네이티브) N-1 이 그대로 적용되어 종료 이벤트를 못 받습니다. **같은 포트, 다른 경로, 다른 결과** — 함정입니다.
|
||||
3. 노출 모델 서술 정정 필요: 8080 은 'Plane B 전용'이 아니라 **MQTT 프로토콜 표면을 함께 노출**합니다(인증은 동일 계정 규칙으로 강제).
|
||||
|
||||
### N-1 재확인 (기존) — retained 는 MQTT 전용
|
||||
`mqttSendRetainedMsgsToNewSubs` 가 `mqttPacketSub` 핸들러에서만 호출되고 MQTT 전용 필드 `sub.mqtt.prm` 를 순회한다는 Rev.2 실측은 유효합니다. 다만 N-7 로 **경계가 프로토콜이지 포트가 아님**이 분명해졌습니다 — MQTT-over-WS 도 MQTT 이므로 retained 를 받습니다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 열린 질문 (비차단)
|
||||
|
||||
계획은 아래 답 없이 그대로 실행 가능합니다. 기본값을 명시했으므로 **응답이 없으면 기본값으로 진행**합니다.
|
||||
|
||||
| # | 질문 | 기본값(무응답 시) |
|
||||
|---|---|---|
|
||||
| **Q-1** | 이미지 핀을 `nats:2.12-alpine`(마이너 추종) 로 둘 것인가, `2.12.15-alpine`(패치 고정) 으로 조일 것인가? | `2.12-alpine` 유지 — 문서 §9.2 와 일치, 보안 패치 자동 수령 |
|
||||
| **Q-2** | 컨테이너를 비루트(`user: "1000:1000"`) 로 돌릴 것인가? | 변경하지 않음 — `nats-data` 볼륨 소유권 초기화가 필요해 무증상 실패 위험. 별도 하드닝 잡으로 분리 |
|
||||
| **Q-3** | `docker/` 를 `deploy/install.sh` 로 하위 워크스페이스에 배포할 것인가? | 배포하지 않음 (D-8) |
|
||||
| **Q-4** | `.mam.env` 의 `MQTT_PASSWORD` ↔ 서버 `docker/.env` 의 `MAM_BROKER_PASS` 동기화를 스크립트화할 것인가? | 수동 — 두 파일이 다른 호스트에 있어 자동화는 시크릿 전송 경로를 새로 만드는 일. README 6절에 절차만 기술 |
|
||||
| **Q-5** 🆕 | 대시보드 프로토콜: MQTT.js(`/mqtt`) 로 통일할 것인가, `nats.ws` 도 허용할 것인가? | **MQTT.js 단일 권장.** retained 를 무상으로 얻고 N-1 함정이 사라짐. `nats.ws` 는 KV/JetStream 같은 NATS 고유 기능이 필요할 때만 |
|
||||
|
||||
> Rev.2(`b11d499d`) 이래 이월되던 *"대시보드를 MQTT 로 붙일 것인가, JetStream 리플레이 스트림의 디스크 관리 부담을 수용할 것인가"* 는 **N-7 로 해소**되어 목록에서 제거했습니다. 브라우저에서도 MQTT 를 쓸 수 있으므로 리플레이 스트림은 불필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 부록 — Creator 착수 체크리스트
|
||||
|
||||
- [ ] `tests/test_deploy_freshness.py` 에 §4.1 헬퍼(`_load_compose`, `_active_conf_lines`) + D-22 ~ D-30 추가 → `pytest -k d22` 가 **FAIL** 하는지 먼저 확인
|
||||
- [ ] `requirements.txt` 에 `PyYAML>=6.0` 추가 (N-2)
|
||||
- [ ] `docker/nats.conf` 작성 — **websocket 주석 블록 포함** (§3.1)
|
||||
- [ ] `docker/docker-compose.yaml` 작성 — 기존 0바이트 파일 덮어쓰기 (§3.2)
|
||||
- [ ] `docker/.env.example` 작성 — 시크릿 4종 **빈 값** 확인 (§3.3)
|
||||
- [ ] `docker/README.md` 작성 — 브라우저 레시피 + 트러블슈팅 12행 (§3.4)
|
||||
- [ ] `PRIVATE_SERVER.md` T-1 ~ T-3, **T-5** (§5)
|
||||
- [ ] `implementation_plan.md` T-4 (§5)
|
||||
- [ ] D-16 단언 정정 (N-3)
|
||||
- [ ] `pytest tests/ -q` → **306 passed**
|
||||
- [ ] §4.3 뮤테이션 **12건** 각각 FAIL 확인 후 원복, 로그 첨부
|
||||
- [ ] `git status` 에 `docker/.env` 부재 확인
|
||||
- [ ] ⚠️ **`allowed_origins: ["*"]` 를 활성 설정으로 넣지 말 것** — 브로커가 기동하지 못합니다 (§A-3, M-22)
|
||||
@@ -0,0 +1,212 @@
|
||||
# Cross-Code Review Report — Job 93a74271
|
||||
|
||||
**Job ID**: 93a74271
|
||||
**Reviewer**: cline
|
||||
**Date**: 2026-08-23
|
||||
**Scope**: Track 1R — Docker deployment assets for `nats-server` on a remote server
|
||||
**Changeset**: 5 new files (`docker/` directory + `requirements.txt`) + 3 modified files (`PRIVATE_SERVER.md`, `implementation_plan.md`, `tests/test_deploy_freshness.py`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This review covers the creation of production-ready Docker deployment assets in the `docker/` directory (`docker-compose.yaml`, `nats.conf`, `.env.example`, `README.md`) and the addition of 9 regression guards (D-22 ~ D-30) in `tests/test_deploy_freshness.py`, along with documentation updates to `PRIVATE_SERVER.md` and `implementation_plan.md`.
|
||||
|
||||
**Verdict**: PASS. All 5 task objectives are met. The Docker assets are correct, internally consistent, and match the canonical documentation. All 306 tests collect; the 29 deploy-freshness guards (including 9 new) pass, and the fast subset (sanity + tier1_unit, 47 tests) shows no regressions. Findings are limited to documentation consistency issues that do not affect the functionality or security of the deployment assets.
|
||||
|
||||
---
|
||||
|
||||
## 2. Task Objective Verification
|
||||
|
||||
### 2.1 docker/docker-compose.yaml — PASS
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|---|---|---|
|
||||
| `nats:2.12-alpine` service | PASS | Line 9: `image: nats:2.12-alpine` |
|
||||
| MQTT 1883 | PASS | Line 22: `"${MQTT_BIND:-127.0.0.1}:1883:1883"` |
|
||||
| NATS 4222 | PASS | Line 23: `"${NATS_BIND:-127.0.0.1}:4222:4222"` |
|
||||
| WS 8080 | PASS | Line 25: `"${WS_BIND:-127.0.0.1}:8080:8080"` |
|
||||
| HTTP monitor 8222 (loopback only) | PASS | Line 24: `"127.0.0.1:8222:8222"` (hardcoded) |
|
||||
| JetStream volume `/data` | PASS | Line 28: `nats-data:/data` |
|
||||
| Healthcheck | PASS | Lines 29-34: `wget` to `/healthz` on `127.0.0.1:8222` |
|
||||
| Fail-closed secrets | PASS | Lines 15-18: `${VAR:?error}` syntax for all 4 secrets |
|
||||
| Log rotation | PASS | Lines 35-37: json-file, 10m max-size, 3 max-file |
|
||||
|
||||
**Note**: The previous review (job `e1c4e9c3`) flagged M-1 (Medium): compose omitted NATS 4222 port. This is now **fixed** — `${NATS_BIND:-127.0.0.1}:4222:4222` is present in both `docker/docker-compose.yaml` and the `PRIVATE_SERVER.md` code fence.
|
||||
|
||||
### 2.2 docker/nats.conf — PASS
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|---|---|---|
|
||||
| MQTT block | PASS | Lines 23-27: `port: 1883`, `ack_wait: 60s`, `max_ack_pending: 1024` |
|
||||
| JetStream block | PASS | Lines 15-19: `store_dir: "/data"`, `max_file: 10G`, `max_mem: 256M` |
|
||||
| Multi-tenant accounts (MAM with mam/observer) | PASS | Lines 55-70: `MAM` account with `mam_agent` + `mam_observer` (sub-only, pub denied) |
|
||||
| HOME account | PASS | Line 72: `HOME: { jetstream: enabled, users: [...] }` |
|
||||
| SYS account | PASS | Line 73: `SYS: { users: [...] }` |
|
||||
| `system_account: SYS` | PASS | Line 75 |
|
||||
| WebSocket block | PASS | Lines 29-52: `port: 8080`, `no_tls: true`, origin policy, `/mqtt` path docs |
|
||||
| No hardcoded secrets | PASS | All passwords are `$VAR` references; D-25 guard verifies |
|
||||
|
||||
### 2.3 docker/.env.example — PASS
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Fail-closed security | PASS | All 4 secrets have empty values (lines 22, 25, 28, 31) |
|
||||
| Variable definitions | PASS | Each secret has a comment explaining purpose and generation method |
|
||||
| Bind address variables | PASS | Lines 37-47: `MQTT_BIND`, `NATS_BIND`, `WS_BIND` (commented, default 127.0.0.1) |
|
||||
| 8222 not variable-ized | PASS | Line 47: explicit note that HTTP monitor is loopback-fixed |
|
||||
| Git tracking | PASS | `.gitignore` line 23 `!.env.example` exempts it; D-29 guard verifies |
|
||||
|
||||
### 2.4 docker/README.md — PASS (with findings — see section 3)
|
||||
|
||||
| Requirement | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Step-by-step deployment | PASS | Section 3: 5-step quick deploy guide |
|
||||
| Verification instructions | PASS | Section 5: R-3 port scan; Section 7: R-1~R-10 playbook |
|
||||
| Network/firewall guidance | PASS | Section 4: UFW rules with Docker bypass warning |
|
||||
| Client connection guide | PASS | Section 6: MAM `.mam.env` config + MQTT.js dashboard recipe |
|
||||
| Operations/maintenance | PASS | Section 8: logs, backup, upgrade, monitoring |
|
||||
| Troubleshooting | PASS | Section 9: 10-row troubleshooting table |
|
||||
|
||||
### 2.5 tests/test_deploy_freshness.py — PASS
|
||||
|
||||
9 new guards added (D-22 ~ D-30), all passing:
|
||||
|
||||
| Guard | What it checks | Result |
|
||||
|---|---|---|
|
||||
| D-22 | docker/ assets exist and are populated | PASS |
|
||||
| D-23 | compose image matches doc and is alpine | PASS |
|
||||
| D-24 | compose port exposure contract (4 ports, 8222 loopback) | PASS |
|
||||
| D-25 | secrets are fail-closed (`${VAR:?}` syntax, empty .env.example, $VAR in nats.conf) | PASS |
|
||||
| D-26 | nats.conf jetstream/mqtt contract (store_dir /data, max_file/max_mem, mqtt 1883, MAM jetstream) | PASS |
|
||||
| D-27 | observer permissions match `mqtt_common.DEFAULT_TOPIC_ROOT` (`python.mqtt.jobs`) | PASS |
|
||||
| D-28 | healthcheck contract (wget, /healthz, 127.0.0.1:8222) and alpine coupling | PASS |
|
||||
| D-29 | env secrets never tracked (`.env` ignored, `.env.example` not ignored) | PASS |
|
||||
| D-30 | websocket origin policy startable (`no_tls: true`, no `*`, `/mqtt` documented) | PASS |
|
||||
|
||||
**D-16 guard change**: Assertion tightened from `"-alpine" in tag or tag.startswith("2.")` to `"alpine" in tag`. Correct — healthcheck requires `wget` only in alpine. All `nats:` references in `PRIVATE_SERVER.md` use `nats:2.12-alpine`; no breakage.
|
||||
|
||||
**Test count**: 306 collected (was 297), matching `implementation_plan.md` claim "297 to 306".
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
### M-1 (Medium) — R-1~R-10 ID collision between PRIVATE_SERVER.md section 9.4 and docker/README.md section 7; R-11~R-13 undefined
|
||||
|
||||
**Category**: Documentation consistency / Loss
|
||||
|
||||
The R-1~R-10 verification playbook IDs have **different meanings** in `PRIVATE_SERVER.md` section 9.4 and `docker/README.md` section 7. Key collisions:
|
||||
|
||||
| R-ID | PRIVATE_SERVER.md section 9.4 | docker/README.md section 7 |
|
||||
|---|---|---|
|
||||
| R-2 | Listener + TLS identity | External monitoring blocked |
|
||||
| R-4 | Round-trip pub/sub + JetStream | WAN latency |
|
||||
| R-5 | **Retained terminal event (MQTT)** | **Auth rejection (unauthorized)** |
|
||||
| R-6 | Broker identity assertion | Auth success (normal) |
|
||||
| R-7 | Freeze regression (H-1/H-4) | Retained event delivery |
|
||||
| R-8 | Full regression suite | Broker identity verification |
|
||||
|
||||
Only R-1 (health), R-3 (port exposure), R-9 (tenant isolation), and R-10 (retained boundary) share the same concept.
|
||||
|
||||
Additionally, `implementation_plan.md` (lines 122, 182) references "R-1 ~ R-13" with "R-5(retained) / R-9(account boundary) / R-13(MQTT-over-WS)" as final gates. However:
|
||||
- R-11, R-12, R-13 are **never defined** in either `PRIVATE_SERVER.md` section 9.4 or `docker/README.md` section 7.
|
||||
- The "R-5(retained)" reference matches `PRIVATE_SERVER.md`'s R-5, but **not** `docker/README.md`'s R-5 (auth rejection).
|
||||
- `PRIVATE_SERVER.md` section 9.5 cutover procedure still says "R-1 ~ R-10" (not R-1~R-13).
|
||||
|
||||
**Impact**: An operator following `docker/README.md` who is told to verify "R-5(retained)" would check auth rejection instead of retained event delivery. The undefined R-11~R-13 create ambiguity about what constitutes the final acceptance gate.
|
||||
|
||||
**Fix**: Either (a) align the README's R-IDs with `PRIVATE_SERVER.md` section 9.4 (use different ID ranges like RR-1~RR-10 for the README's deployment-focused checks), or (b) define R-11~R-13 in both documents and update section 9.5 to reference R-1~R-13.
|
||||
|
||||
### L-1 (Low) — docker/README.md section 7 R-4 references non-existent `latency_check.py`
|
||||
|
||||
**Category**: Operability / Loss
|
||||
|
||||
`docker/README.md` section 7 R-4 states: `python latency_check.py` with expected result "RTT P95 < 150ms". No such file exists in the repository. `PRIVATE_SERVER.md` section 9.4 defines the latency probe as an inline Python heredoc (not a standalone script). An operator following the README would get a "file not found" error.
|
||||
|
||||
**Fix**: Either (a) replace `python latency_check.py` with the inline heredoc from `PRIVATE_SERVER.md` section 9.4, or (b) create `docker/latency_check.py` as a standalone script, or (c) reference the `PRIVATE_SERVER.md` section 9.4 latency probe section.
|
||||
|
||||
### L-2 (Low) — docker/README.md section 4 UFW rules more permissive than PRIVATE_SERVER.md section 9.3
|
||||
|
||||
**Category**: Documentation consistency
|
||||
|
||||
`docker/README.md` section 4 uses `sudo ufw allow in on tailscale0 to any` (allows all ports on the tailnet interface), while `PRIVATE_SERVER.md` section 9.3 uses granular per-port rules (`port 1883`, `port 4222`, `port 8080`). Both are valid for a trusted tailnet, but the README's approach is less defense-in-depth. The README also omits the 4222 UFW rule that `PRIVATE_SERVER.md` section 9.3 includes.
|
||||
|
||||
**Fix**: Align the README's UFW rules with `PRIVATE_SERVER.md` section 9.3's per-port approach, or add a note explaining the intentional difference.
|
||||
|
||||
### V-1 (Very Low) — implementation_plan.md P0.5 step indentation
|
||||
|
||||
**Category**: Cosmetic
|
||||
|
||||
The new P0.5 step in the roadmap diagram uses slightly different indentation alignment than the surrounding steps. Purely cosmetic; does not affect readability of the plan.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Reference Verification
|
||||
|
||||
### 4.1 docker/ files vs PRIVATE_SERVER.md code fences
|
||||
|
||||
| File | Method | Result |
|
||||
|---|---|---|
|
||||
| `docker/nats.conf` vs `PRIVATE_SERVER.md` section 9.1 code fence | Programmatic byte-level comparison | **EXACT MATCH** |
|
||||
| `docker/docker-compose.yaml` vs `PRIVATE_SERVER.md` section 9.2 code fence | Programmatic byte-level comparison | **EXACT MATCH** |
|
||||
|
||||
The D-22~D-30 guards provide structural verification but not a full byte-level diff. The manual programmatic comparison confirms zero drift between the canonical files and the documentation code fences.
|
||||
|
||||
### 4.2 Topic root consistency
|
||||
|
||||
`mqtt_common.DEFAULT_TOPIC_ROOT` = `"python/mqtt/jobs"` -> dotted form = `"python.mqtt.jobs"`.
|
||||
`docker/nats.conf` observer `subscribe: { allow: ["python.mqtt.jobs.>"] }` — matches. D-27 guard verifies this programmatically.
|
||||
|
||||
### 4.3 .gitignore verification
|
||||
|
||||
- `docker/.env` -> ignored by `.gitignore` line 21 (`.env` pattern). D-29 guard passes.
|
||||
- `docker/.env.example` -> not ignored (`.gitignore` line 23 `!.env.example` overrides line 22 `.env.*`). D-29 guard passes.
|
||||
- `.env.example` lines 4-5 reference `.gitignore:23` and `.gitignore:21` — line numbers verified correct.
|
||||
|
||||
### 4.4 Previous review findings (job e1c4e9c3) — resolution status
|
||||
|
||||
| Previous Finding | Status | Evidence |
|
||||
|---|---|---|
|
||||
| M-1: section 9.2 compose omits NATS 4222 port | **FIXED** | Both `docker/docker-compose.yaml` and `PRIVATE_SERVER.md` section 9.2 include `${NATS_BIND:-127.0.0.1}:4222:4222` |
|
||||
| V-2: Unchecked M2b guard checkbox | **FIXED** | `implementation_plan.md` line 180: `- [x]` for guards G-D5~G-D9, G-R1, G-R2 (290 to 297) |
|
||||
| L-1/L-2 (lib.sh latency, handle_startup_dialogs timeout) | Out of scope | Not part of this changeset |
|
||||
| L-3 (D-19 regex scans full markdown) | Still present | D-19 unchanged; not part of this changeset |
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Results
|
||||
|
||||
| Suite | Tests | Result |
|
||||
|---|---|---|
|
||||
| `tests/test_deploy_freshness.py` (full) | 29 | **29 passed** (12.16s) |
|
||||
| `tests/test_sanity.py` + `tests/test_tier1_unit.py` | 47 | **47 passed** (16.99s) |
|
||||
| Full collection | 306 | 306 collected (0.04s) — matches `implementation_plan.md` "297 to 306" |
|
||||
| Full suite (`tests/`) | 306 | Not completed (integration/e2e tests with MQTT exceed 30s timeout; not affected by this changeset) |
|
||||
|
||||
**No regressions detected** in the fast subset. The D-16 guard tightening is validated by all 29 deploy-freshness tests passing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security Review
|
||||
|
||||
| Check | Status |
|
||||
|---|---|
|
||||
| No hardcoded secrets in any file | PASS — All passwords are `$VAR` references; D-25 guard verifies |
|
||||
| Fail-closed on missing secrets | PASS — `${VAR:?error}` compose syntax; empty `.env.example` values |
|
||||
| 8222 (HTTP monitor) loopback-only | PASS — Hardcoded `127.0.0.1:8222:8222`, not variable-ized |
|
||||
| Default bind addresses are loopback | PASS — `${MQTT_BIND:-127.0.0.1}`, `${NATS_BIND:-127.0.0.1}`, `${WS_BIND:-127.0.0.1}` |
|
||||
| `.env` never tracked in git | PASS — `.gitignore` + D-29 guard |
|
||||
| WebSocket `no_tls: true` explicit | PASS — Prevents startup failure from implicit TLS requirement |
|
||||
| No `*` in `allowed_origins` | PASS — D-30 guard verifies; comment explains NATS rejects `*` |
|
||||
| Observer publish denied | PASS — `publish: { deny: [">"] }` in nats.conf; D-27 guard verifies |
|
||||
|
||||
---
|
||||
|
||||
## 7. Conclusion
|
||||
|
||||
The implementation fully satisfies all 5 task objectives. The Docker deployment assets are production-ready, internally consistent, and match the canonical documentation byte-for-byte. The 9 new regression guards (D-22~D-30) provide comprehensive structural verification of the deployment contract. The previous review's M-1 finding (missing 4222 port) is resolved.
|
||||
|
||||
The findings (1 Medium, 2 Low, 1 Very Low) are all documentation consistency issues that do not affect the functionality or security of the deployment assets. They can be addressed with minor documentation edits without rework.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+86
-39
@@ -193,7 +193,7 @@ persistence_location /mosquitto/data/
|
||||
|
||||
`nats-server`의 다능성은 **MAM을 네이티브 NATS로 이관할 이유가 아니라, MAM 코드를 한 줄도 바꾸지 않고도 얻을 수 있는 부가적 이득**입니다.
|
||||
|
||||
### 5.1 두 개의 소비 평면 (Two Consumption Planes)
|
||||
### 5.1 두 개의 소비 평면 및 세 가지 접속 경로 (Consumption Planes & Transport Paths)
|
||||
|
||||
`nats-server`는 단일 프로세스 내에서 여러 프로토콜 리스너를 동시에 구동하므로, MAM의 단순성과 개인 홈랩의 확장성을 완벽히 양립시킵니다.
|
||||
|
||||
@@ -204,18 +204,25 @@ persistence_location /mosquitto/data/
|
||||
│ 평면 A: MAM 워크로드 │ 평면 B: 홈랩/개인 프로젝트 │
|
||||
├─────────────────────────────┼─────────────────────────────┤
|
||||
프로토콜 │ MQTT 3.1.1 (포트 1883) │ NATS(4222), WebSocket(8080) │
|
||||
클라이언트 │ paho-mqtt (코드 변경 0줄) │ nats-py, nats.js, CLI 등 자유 │
|
||||
클라이언트 │ paho-mqtt (코드 변경 0줄) │ nats-py, nats.js, MQTT.js 등│
|
||||
사용 기능 │ QoS 1, Retain, 와일드카드, TLS│ JetStream 리플레이, KV, Object│
|
||||
설계 원칙 │ 초경량 동기 CLI 핫패스 보존 │ 고급 비동기 이벤트 스트리밍 │
|
||||
공유 자원 │ └───── 단일 정적 바이너리 / JetStream 스토리지 / ACL ─────┘│
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
특히 **WebSocket 포트(8080)**는 접속 경로(URL Path)에 따라 두 가지 프로토콜을 동시에 서빙합니다:
|
||||
1. **MQTT 1883**: TCP 네이티브 MQTT 3.1.1 (MAM CLI 에이전트 표준 경로)
|
||||
2. **WebSocket 8080 (`/mqtt` 경로)**: **MQTT-over-WebSocket** (`MQTT.js` 등으로 접속). 서버 내부에서 1883 리스너와 동일하게 취급되어 **retained 종료 이벤트를 완전하게 수신**합니다 (N-7).
|
||||
3. **WebSocket 8080 (`/` 또는 경로 없음)**: **NATS 네이티브 WebSocket** (`nats.ws` 등으로 접속). 라이브 스트림만 수신하며 retained 메시지는 수신하지 않습니다 (N-1).
|
||||
|
||||
### 5.2 교차 프로토콜 브리징 (Cross-Protocol Bridging)
|
||||
- `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 리플레이 스트림을 옵트인하십시오.
|
||||
- **경계 (필수 인지 — N-1 & N-7)**:
|
||||
- NATS 네이티브(경로 없는 WebSocket 또는 4222)로 접속하는 경우: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. 잡이 끝난 뒤 접속한 네이티브 NATS 대시보드는 그 잡의 **종료 이벤트를 수신하지 못합니다** (retained 메시지는 MQTT SUBSCRIBE 경로 전용).
|
||||
- 웹 대시보드에서 사후 종료 이벤트까지 무상으로 수신하려면 **`ws://<host>:8080/mqtt` 경로로 `MQTT.js` 클라이언트를 연결**하십시오 (N-7). 리플레이 스트림 구축 및 디스크 관리 부담 없이 완전한 종료 이벤트를 즉시 수신할 수 있습니다.
|
||||
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자(`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject가 보이지 않습니다.
|
||||
- **주의 사항**: 토픽 레벨 내에 마침표(`.`)가 포함되면 NATS 계층에서 토큰이 분리될 수 있으나, MAM의 `job_id`는 8자리 hex, 워크스페이스 지문은 12자리 hex이므로 안전합니다.
|
||||
|
||||
@@ -332,41 +339,74 @@ Step 2의 `-v` 출력 로그 또는 `.mam/delegate_job_logs/$JID/events.ndjson`
|
||||
|
||||
원격 VPS 또는 상시 가동 홈랩 서버에 프로덕션 수준의 `nats-server`를 Docker 기반으로 구축하고 MAM과 연동하는 표준 절차입니다.
|
||||
|
||||
> [!NOTE]
|
||||
> **정본 자산 안내**: 본 절의 설정은 [`docker/`](docker/) 디렉터리에 정본(canonical) 파일(`docker/docker-compose.yaml`, `docker/nats.conf`, `docker/.env.example`, `docker/README.md`)로 관리되며, 아래 코드 펜스는 그 사본입니다. 두 곳이 어긋나면 `tests/test_deploy_freshness.py`의 D-22 ~ D-30 회귀 가드가 실패합니다.
|
||||
|
||||
### 9.1 프로덕션 `nats.conf`
|
||||
|
||||
```conf
|
||||
# nats.conf — 원격 프로덕션 (컨테이너 내부 절대경로 기준)
|
||||
# ==============================================================================
|
||||
# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
#
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.1
|
||||
# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose
|
||||
# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다.
|
||||
#
|
||||
# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다.
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오.
|
||||
# ==============================================================================
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. MAM 종료 이벤트 재수신이 여기에 의존 ──
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # D-1: 절대경로 고정. '~' 도, 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G
|
||||
store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T)
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # D-3: 호스트 게시는 loopback 한정 (§9.3)
|
||||
http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose)
|
||||
|
||||
mqtt {
|
||||
port: 1883 # 공개 TLS 모델에서는 8883 + tls 블록 (§9.3)
|
||||
port: 1883
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 기본 100 → 다중 에이전트 동시 발행 여유
|
||||
max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535)
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정
|
||||
no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패
|
||||
|
||||
# ── 원점(Origin) 정책 ────────────────────────────────────────────────
|
||||
# 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고
|
||||
# allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고
|
||||
# 즉시 nil 을 반환합니다 (server/websocket.go:1039).
|
||||
# → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다.
|
||||
# → `same_origin: false` 를 적는 것은 no-op 입니다.
|
||||
#
|
||||
# 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오.
|
||||
# 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'.
|
||||
# "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다
|
||||
# (websocket.go:1142 "must be absolute URLs with http or https scheme").
|
||||
# allowed_origins: ["https://dashboard.example", "http://localhost:3000"]
|
||||
|
||||
# ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ───────────────────────
|
||||
# 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193,
|
||||
# websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수).
|
||||
# ws://<host>:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다.
|
||||
# ws://<host>:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1).
|
||||
# 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다.
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 (Rev.2: C1/C2 반영) ────────────────────────────────
|
||||
# ── 인증 및 멀티테넌시 ────────────────────────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME)
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. PRIVATE_SERVER.md §5.5 의 '동일 계정 배치' 처방
|
||||
# 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치
|
||||
{ user: mam_observer, password: $MAM_OBSERVER_PASS,
|
||||
permissions: {
|
||||
subscribe: { allow: ["python.mqtt.jobs.>"] } # M3 이후: "mam.<fp>.jobs.>"
|
||||
@@ -385,31 +425,37 @@ system_account: SYS
|
||||
> [!IMPORTANT]
|
||||
> **관측자는 반드시 `MAM` 계정 안에 둡니다.** NATS 계정은 하드 격리 경계이므로 `user: home`(계정 `HOME`)으로 접속한 클라이언트는 `python.mqtt.jobs.>`를 구독해도 **0건**을 받습니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 **부록 X(Option A)** 를 따르십시오.
|
||||
|
||||
### 9.2 프로덕션 `docker-compose.yml`
|
||||
### 9.2 프로덕션 `docker/docker-compose.yaml`
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
# ==============================================================================
|
||||
# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.2
|
||||
#
|
||||
# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d
|
||||
# ==============================================================================
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # D-2: latest(=scratch)는 healthcheck 불가
|
||||
image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음
|
||||
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}
|
||||
# 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.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"
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라
|
||||
# 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback.
|
||||
- "${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" # 무인증 모니터링 — loopback 고정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
- nats-data:/data # nats.conf 의 store_dir 와 일치
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 30s
|
||||
@@ -451,18 +497,19 @@ sudo ufw allow in on tailscale0 to any port 8080 proto tcp
|
||||
sudo ufw enable && sudo ufw status verbose
|
||||
```
|
||||
|
||||
**시크릿 생성 및 바인드 주소 주입 (`.env`):**
|
||||
**시크릿 생성 및 환경변수 주입 (`docker/.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
|
||||
# docker/.env.example 복사 및 권한 제한
|
||||
cd docker && cp .env.example .env && chmod 600 .env
|
||||
|
||||
# 시크릿 암호 생성 (openssl rand -base64 32 사용)
|
||||
# .env 파일을 열고 생성된 시크릿 및 바인드 주소 입력:
|
||||
# MAM_BROKER_PASS=<생성된_토큰>
|
||||
# MAM_OBSERVER_PASS=<생성된_토큰>
|
||||
# HOME_BROKER_PASS=<생성된_토큰>
|
||||
# SYS_BROKER_PASS=<생성된_토큰>
|
||||
# MQTT_BIND=$(tailscale ip -4)
|
||||
# WS_BIND=$(tailscale ip -4)
|
||||
```
|
||||
|
||||
### 9.4 원격 검증 플레이북 (R-1 ~ R-10)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ==============================================================================
|
||||
# docker/.env.example — 원격 브로커 서버 측 환경변수 템플릿
|
||||
#
|
||||
# 이 파일은 git 에 커밋됩니다 (.gitignore:23 의 `!.env.example`).
|
||||
# 복사본 docker/.env 는 git 에서 제외됩니다 (.gitignore:21 의 `.env`).
|
||||
#
|
||||
# cd docker && cp .env.example .env && chmod 600 .env
|
||||
#
|
||||
# ⚠ 아래 시크릿 4종은 의도적으로 **빈 값**입니다. 채우지 않고 그대로 복사하면
|
||||
# `docker compose up` 이 컨테이너를 만들기 전에 실패합니다 (fail-closed).
|
||||
# 플레이스홀더 문자열을 넣지 마십시오 — '알려진 암호로 가동'이 최악입니다.
|
||||
# ==============================================================================
|
||||
|
||||
# ── 시크릿 (필수) ────────────────────────────────────────────────────────
|
||||
# 생성: openssl rand -base64 32
|
||||
#
|
||||
# ⚠ 반드시 위 명령을 사용하십시오. NATS 는 환경변수 값을 설정 렉서로 재파싱하므로
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 포함되면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# base64 알파벳(A-Za-z0-9+/=)은 이 문자들과 교집합이 없어 안전합니다.
|
||||
|
||||
# MAM 에이전트 발행/구독 계정 (.mam.env 의 MQTT_PASSWORD 와 동일 값)
|
||||
MAM_BROKER_PASS=
|
||||
|
||||
# 관측 전용 계정 (구독 allow: python.mqtt.jobs.>, 발행 전면 deny)
|
||||
MAM_OBSERVER_PASS=
|
||||
|
||||
# MAM 과 무관한 홈랩 서비스용 별도 계정
|
||||
HOME_BROKER_PASS=
|
||||
|
||||
# 시스템 계정 ($SYS). 운영 이벤트/모니터링 전용
|
||||
SYS_BROKER_PASS=
|
||||
|
||||
# ── 리스너 바인드 주소 (선택 — 미설정 시 전부 127.0.0.1) ──────────────────
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 아래 값이 실질적인 노출 통제입니다.
|
||||
# 모델 T(Tailscale) 권장 설정: MQTT_BIND=$(tailscale ip -4)
|
||||
#
|
||||
# MQTT 1883 리스너 바인드 주소
|
||||
# MQTT_BIND=127.0.0.1
|
||||
#
|
||||
# NATS 4222 네이티브 리스너 바인드 주소
|
||||
# NATS_BIND=127.0.0.1
|
||||
#
|
||||
# WebSocket 8080 바인드 주소.
|
||||
# 참고: 이 포트는 NATS WebSocket 과 MQTT-over-WebSocket(/mqtt)을 함께 서빙합니다.
|
||||
# WS_BIND=127.0.0.1
|
||||
#
|
||||
# HTTP 모니터 8222 는 무인증이므로 바인드 주소를 변수화하지 않습니다 (loopback 고정).
|
||||
@@ -0,0 +1,158 @@
|
||||
# 🐳 MAM Remote Broker Production Deployment (`docker/`)
|
||||
|
||||
이 디렉터리는 multi-agent-mux (MAM) 멀티 에이전트 관측 백플레인을 위한 원격 `nats-server` 프로덕션 브로커 배포 자산의 **정본(canonical)**입니다. 상세 아키텍처 및 배경 지식은 [`PRIVATE_SERVER.md`](../PRIVATE_SERVER.md) §9 를 참조하십시오.
|
||||
|
||||
---
|
||||
|
||||
## 1. 무엇인가
|
||||
|
||||
MAM 은 멀티 에이전트 간 비동기 작업 조정 및 이벤트 스트리밍 백플레인으로 `nats-server` (MQTT 3.1.1 + JetStream + WebSocket) 를 사용합니다. 본 디렉터리의 Compose 파일과 설정은 사설 오버레이 네트워크(Tailscale 등)를 통해 0개의 공인 포트 개방으로 안전하게 운영되는 단일 브로커 허브(`mam-hub`)를 배포합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 사전 요구사항
|
||||
|
||||
- **Docker Engine** (24.0+) 및 **Docker Compose V2** (`docker compose` CLI)
|
||||
- **Tailscale** 또는 WireGuard 사설 VPN (권장: 모델 T 사설 오버레이)
|
||||
- 호스트 방화벽 (UFW 등) 기본 차단 정책 (공인 IP 개방 포트 없음)
|
||||
|
||||
---
|
||||
|
||||
## 3. 5분 빠른 배포
|
||||
|
||||
```bash
|
||||
# 1. docker/ 디렉터리를 대상 원격 서버로 복사
|
||||
rsync -avz ./docker/ user@your-server:~/mam-broker/
|
||||
|
||||
# 2. 서버 접속 후 환경변수 템플릿 복사 및 권한 제한
|
||||
cd ~/mam-broker
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
|
||||
# 3. 4종의 시크릿 암호 생성 및 .env 에 입력
|
||||
# ⚠ 반드시 openssl rand -base64 32 를 사용하십시오 (렉서 종결자 충돌 방지)
|
||||
openssl rand -base64 32 # MAM_BROKER_PASS
|
||||
openssl rand -base64 32 # MAM_OBSERVER_PASS
|
||||
openssl rand -base64 32 # HOME_BROKER_PASS
|
||||
openssl rand -base64 32 # SYS_BROKER_PASS
|
||||
|
||||
# .env 파일 편집 후 시크릿 채우기:
|
||||
nano .env
|
||||
|
||||
# (선택) Tailscale 사설 IP로 바인드 설정:
|
||||
# MQTT_BIND=$(tailscale ip -4)
|
||||
# NATS_BIND=$(tailscale ip -4)
|
||||
# WS_BIND=$(tailscale ip -4)
|
||||
|
||||
# 4. 컨테이너 기동
|
||||
docker compose up -d
|
||||
|
||||
# 5. 상태 및 헬스체크 확인
|
||||
docker compose ps
|
||||
# STATUS 열에 "(healthy)" 가 표시되는지 확인
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 네트워크 잠금 및 방화벽 설정
|
||||
|
||||
> [!WARNING]
|
||||
> **Docker 의 published 포트(`ports:`)는 Linux 호스트의 UFW 방화벽 규칙을 기본적으로 우회(Bypass)합니다.**
|
||||
> 실제 노출 통제는 방화벽이 아닌 `docker-compose.yaml` 의 바인드 주소(`${MQTT_BIND:-127.0.0.1}`)가 담당합니다.
|
||||
|
||||
UFW 호스트 방화벽 기본 규칙:
|
||||
```bash
|
||||
sudo ufw default deny incoming
|
||||
sudo ufw default allow outgoing
|
||||
sudo ufw allow in on tailscale0 to any
|
||||
sudo ufw allow ssh
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 외부 노출 면적 검증 (R-3)
|
||||
|
||||
외부 인터넷(Tailnet 밖) 클라이언트에서 공인 IP 포트 스캔을 수행하여 브로커가 인터넷에 노출되지 않았음을 검증합니다:
|
||||
|
||||
```bash
|
||||
nmap -Pn -p 1883,4222,8222,8080 <공개_IP>
|
||||
```
|
||||
모든 포트가 `closed` 또는 `filtered` 로 표시되어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 클라이언트 연결
|
||||
|
||||
### (a) MAM 에이전트 환경변수 (`.mam.env`)
|
||||
로컬 개발 머신의 MAM 워크스페이스 최상위 `.mam.env` 에 브로커 접속 정보를 설정합니다:
|
||||
```bash
|
||||
MQTT_BROKER=100.x.y.z # 또는 mam-hub.your-tailnet.ts.net
|
||||
MQTT_PORT=1883
|
||||
MQTT_TLS=0
|
||||
MQTT_USERNAME=mam_agent
|
||||
MQTT_PASSWORD=<MAM_BROKER_PASS_값>
|
||||
```
|
||||
|
||||
### (b) 브라우저 대시보드 접속 레시피 (N-7 반영)
|
||||
|
||||
```javascript
|
||||
// MQTT.js — retained 종료 이벤트까지 정상 수신하는 권장 경로 (N-7)
|
||||
const client = mqtt.connect("ws://mam-hub.your-tailnet.ts.net:8080/mqtt", {
|
||||
username: "mam_observer",
|
||||
password: "<MAM_OBSERVER_PASS_값>",
|
||||
protocolVersion: 4, // MQTT 3.1.1
|
||||
});
|
||||
|
||||
client.subscribe("python/mqtt/jobs/+/events");
|
||||
// 이 클라이언트는 nats-server 내부에서 완전한 MQTT 클라이언트로 취급되므로
|
||||
// 잡이 끝난 뒤에 대시보드를 새로고침해도 retained 최종 이벤트를 즉시 수신합니다.
|
||||
|
||||
// 참고 (nats.ws):
|
||||
// ws://<host>:8080/ (경로 없이) 접속 시 NATS 네이티브로 동작하며,
|
||||
// 실시간 스트림만 수신하고 과거 종료된 잡의 retained 이벤트는 받지 못합니다 (N-1).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 원격 검증 플레이북 (R-1 ~ R-10)
|
||||
|
||||
| ID | 검증 항목 | 명령어 / 방법 | 기대 결과 |
|
||||
|---|---|---|---|
|
||||
| **R-1** | 헬스 엔드포인트 | `curl -f http://127.0.0.1:8222/healthz` (호스트 내부) | HTTP 200 `{"status":"ok"}` |
|
||||
| **R-2** | 외부 모니터링 차단 | 원격 머신에서 `curl http://<공개IP>:8222/healthz` | Connection refused / Timeout |
|
||||
| **R-3** | 포트 노출 면적 | `nmap -Pn -p 1883,4222,8222,8080 <공개IP>` | 4개 포트 전부 Closed / Filtered |
|
||||
| **R-4** | WAN 지연 시간 | `python latency_check.py` | RTT P95 < 150ms |
|
||||
| **R-5** | 인증 거부 (무인가) | `mosquitto_pub -h <host> -p 1883 -t test -m hi` | Connect return code 5 (Not authorized) |
|
||||
| **R-6** | 인증 성공 (정상) | `mosquitto_pub -h <host> -p 1883 -u mam_agent -P <pw> -t test -m hi` | 메시지 발행 성공 (rc=0) |
|
||||
| **R-7** | Retained 이벤트 전달 | `mosquitto_sub -h <host> -p 1883 -u mam_agent -P <pw> -t 'python/mqtt/jobs/+/events' -C 1` | Retained 종료 이벤트 즉시 덤프 |
|
||||
| **R-8** | 브로커 식별자 검증 | `curl -s http://127.0.0.1:8222/varz \| jq .server_name` | `"mam-hub"` |
|
||||
| **R-9** | 테넌트 계정 격리 | `mam_observer` 로 구독 성공, `home` 계정으로 구독 시 MAM 이벤트 수신 0건 | 테넌트 완벽 격리 |
|
||||
| **R-10** | Retained 경계 검증 | MQTT 구독자는 retained 수신, 네이티브 WS(경로없음) 구독자는 0건 | N-1 / N-7 경계 확증 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 운영 및 유지보수
|
||||
|
||||
- **로그 확인**: `docker compose logs -f --tail=100 nats` (기본 json-file 10MB x 3 로테이션 내장)
|
||||
- **JetStream 볼륨 백업**: `docker run --rm -v mam-broker_nats-data:/data -v $(pwd):/backup alpine tar czf /backup/nats-data-$(date +%Y%m%d).tar.gz /data`
|
||||
- **버전 업그레이드**: `docker compose pull && docker compose up -d`
|
||||
- **모니터링 지표 조회**: SSH 터널링을 통해 `http://127.0.0.1:8222/varz`, `/connz`, `/jsz` 안전하게 확인
|
||||
|
||||
---
|
||||
|
||||
## 9. 트러블슈팅
|
||||
|
||||
| 증상 | 원인 | 해결 조치 |
|
||||
|---|---|---|
|
||||
| `required variable MAM_BROKER_PASS is missing` | `.env` 미생성 또는 빈 값 | 의도된 fail-closed 동작. `chmod 600 .env` 후 시크릿을 채우십시오. |
|
||||
| `variable reference for 'MAM_BROKER_PASS' … can not be found` | compose 는 통과했으나 컨테이너에 환경변수 미주입 | `docker-compose.yaml` 의 `environment:` 블록 누락 확인 |
|
||||
| 컨테이너는 뜨는데 계속 `unhealthy` | 이미지를 `latest`/`scratch`/non-alpine 로 변경하여 `wget` 부재 | `image: nats:2.12-alpine` 로 복구하십시오. |
|
||||
| 설정이 알 수 없는 값으로 파싱되거나 깨짐 | 암호에 NATS 렉서 종결자(`; , ] } # ' " $` 등) 포함 | `openssl rand -base64 32` 로 알파벳/숫자/기본 base64 암호를 재발급하십시오. |
|
||||
| `max_file` 구문 에러 | `10g` 등 소문자 크기 접미사 사용 | NATS 는 대문자 접미사(`10G`, `256M`)만 허용합니다. |
|
||||
| 원격(Tailnet)에서 접속 불가 | 리스너 바인드 주소가 loopback(127.0.0.1) 기본값으로 유지됨 | `.env` 에 `MQTT_BIND=$(tailscale ip -4)` 등으로 Tailnet IP를 명시하십시오. |
|
||||
| `JetStream not enabled for account` | 계정 정의에 `jetstream: enabled` 누락 | `nats.conf` 의 `accounts.MAM` 블록 확인 |
|
||||
| **WebSocket `403 origin not allowed`** | **기본 설정에서는 발생하지 않습니다.** `allowed_origins` 를 채웠거나 `same_origin: true` 를 켠 경우에만 발동 | 해당 설정을 지우거나, 대시보드 URL을 `allowed_origins` 에 추가하십시오. |
|
||||
| **서버가 `allowed origins must be absolute URLs…` 로 기동 실패** | `allowed_origins` 에 `"*"` 또는 scheme 없는 값 입력 | `["https://dashboard.example", "http://localhost:3000"]` 처럼 절대 URL로 입력하십시오 (`"*"` 불가). |
|
||||
| **브라우저 콘솔의 Mixed Content 차단** | `https://` 페이지에서 `ws://` 연결 시도 | 대시보드를 tailnet 내부 `http://` 로 서빙하거나, 리버스 프록시로 `wss://` 종단을 제공하십시오. |
|
||||
| **대시보드가 종료 이벤트를 못 받음** | 8080 포트에 **경로 없이**(NATS 네이티브) 접속함. retained 는 MQTT 전용 (N-1) | `ws://<host>:8080/mqtt` 로 MQTT.js 클라이언트로 접속하십시오 (N-7). |
|
||||
@@ -0,0 +1,40 @@
|
||||
# ==============================================================================
|
||||
# docker/docker-compose.yaml — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.2
|
||||
#
|
||||
# 사용법: cd docker && cp .env.example .env && <시크릿 채우기> && docker compose up -d
|
||||
# ==============================================================================
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.12-alpine # alpine 필수: healthcheck 의 wget 이 여기에만 있음
|
||||
container_name: mam-nats
|
||||
restart: unless-stopped
|
||||
command: ["-c", "/etc/nats/nats.conf"]
|
||||
environment:
|
||||
# 미설정/빈 값이면 컨테이너 생성 전에 compose 가 중단 → fail-closed
|
||||
MAM_BROKER_PASS: ${MAM_BROKER_PASS:?set MAM_BROKER_PASS in docker/.env}
|
||||
MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set MAM_OBSERVER_PASS in docker/.env}
|
||||
HOME_BROKER_PASS: ${HOME_BROKER_PASS:?set HOME_BROKER_PASS in docker/.env}
|
||||
SYS_BROKER_PASS: ${SYS_BROKER_PASS:?set SYS_BROKER_PASS in docker/.env}
|
||||
ports:
|
||||
# ⚠ Docker 의 published 포트는 UFW 를 우회합니다. 노출 통제는 방화벽이 아니라
|
||||
# 여기의 바인드 주소가 담당합니다. 기본값은 전부 loopback.
|
||||
- "${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" # 무인증 모니터링 — loopback 고정
|
||||
- "${WS_BIND:-127.0.0.1}:8080:8080" # WebSocket (NATS + /mqtt 경로의 MQTT)
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data # nats.conf 의 store_dir 와 일치
|
||||
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:
|
||||
@@ -0,0 +1,75 @@
|
||||
# ==============================================================================
|
||||
# docker/nats.conf — MAM 원격 프로덕션 브로커 (Track 1R)
|
||||
#
|
||||
# 정본 문서: PRIVATE_SERVER.md §9.1
|
||||
# 시크릿: 이 파일에는 없습니다. 모든 password 는 docker/.env → compose
|
||||
# environment → 컨테이너 환경변수로 주입되는 $VAR 참조입니다.
|
||||
#
|
||||
# ⚠ 암호 문자 제약: NATS 는 환경변수 값을 자체 설정 렉서로 재파싱합니다.
|
||||
# 암호에 [공백 ; , ] } # ' " $] 가 들어가면 설정이 깨지거나 다르게 해석됩니다.
|
||||
# 반드시 `openssl rand -base64 32` (알파벳 A-Za-z0-9+/=) 를 사용하십시오.
|
||||
# ==============================================================================
|
||||
server_name: mam-hub
|
||||
|
||||
# ── JetStream: MQTT retained/QoS1 저장소. 종료 이벤트 재수신이 여기에 의존 ──
|
||||
jetstream {
|
||||
store_dir: "/data" # 절대경로 고정. '~' 도 인용된 "$HOME" 도 확장되지 않음
|
||||
max_file: 10G # 접미사는 대문자만 유효 (K/M/G/T)
|
||||
max_mem: 256M
|
||||
}
|
||||
|
||||
http_port: 8222 # 무인증 모니터링 → 호스트 게시는 loopback 한정 (compose)
|
||||
|
||||
mqtt {
|
||||
port: 1883
|
||||
ack_wait: 60s # WAN RTT 흡수 (기본 30s)
|
||||
max_ack_pending: 1024 # 다중 에이전트 동시 발행 여유 (상한 65535)
|
||||
}
|
||||
|
||||
websocket {
|
||||
port: 8080
|
||||
no_tls: true # 사설망/tailnet 한정. 생략하면 TLS 설정 필수라 기동 실패
|
||||
|
||||
# ── 원점(Origin) 정책 ────────────────────────────────────────────────
|
||||
# 기본값은 이미 '모든 출처 허용'입니다. same_origin 의 기본값은 false 이고
|
||||
# allowed_origins 가 비어 있으면 checkOrigin() 이 Origin 헤더를 읽지도 않고
|
||||
# 즉시 nil 을 반환합니다 (server/websocket.go:1039).
|
||||
# → http://localhost:3000 의 브라우저 대시보드는 별도 설정 없이 접속됩니다.
|
||||
# → `same_origin: false` 를 적는 것은 no-op 입니다.
|
||||
#
|
||||
# 8080 을 tailnet 밖으로 노출한다면 아래를 켜서 출처를 좁히십시오.
|
||||
# 주의: allowed_origins 를 비우지 않는 순간 원점 검사가 '켜집니다'.
|
||||
# "*" 는 절대 쓰지 마십시오 — 옵션 검증 실패로 서버가 기동하지 못합니다
|
||||
# (websocket.go:1142 "must be absolute URLs with http or https scheme").
|
||||
# allowed_origins: ["https://dashboard.example", "http://localhost:3000"]
|
||||
|
||||
# ── 이 포트는 MQTT-over-WebSocket 도 서빙합니다 ───────────────────────
|
||||
# 경로 /mqtt 로 붙으면 완전한 MQTT 클라이언트가 됩니다 (mqtt.go:193,
|
||||
# websocket.go:1335 → createMQTTClient, 1883 리스너와 동일 함수).
|
||||
# ws://<host>:8080/mqtt → MQTT. retained 종료 이벤트를 받습니다.
|
||||
# ws://<host>:8080/ → NATS 네이티브. retained 를 받지 못합니다 (N-1).
|
||||
# 브라우저 대시보드는 MQTT.js 로 /mqtt 에 붙이는 것을 권장합니다.
|
||||
}
|
||||
|
||||
# ── 인증 및 멀티테넌시 ────────────────────────────────────────────────────
|
||||
accounts {
|
||||
MAM: {
|
||||
jetstream: enabled # MQTT 내부 스트림이 이 계정 안에 생성됨
|
||||
users: [
|
||||
# 발행자 겸 구독자 — MAM 에이전트 본체 (.mam.env 의 MQTT_USERNAME)
|
||||
{ user: mam_agent, password: $MAM_BROKER_PASS }
|
||||
|
||||
# 관측자 — 대시보드/모니터링. 반드시 MAM 계정 안에 위치
|
||||
{ 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
|
||||
@@ -109,6 +109,8 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2a (로컬 스
|
||||
```
|
||||
[P0 교정] D-1~D-5 문서 교정 + §5.2 경계 명문화 + 신규 가드 7종
|
||||
▼
|
||||
[P0.5 자산화] docker/ 4대 자산 정본화 + D-22~D-30 회귀 가드 (297 -> 306)
|
||||
▼
|
||||
[P1 서버 준비] VPS/홈랩 프로비저닝, Docker, Tailscale 가입
|
||||
▼
|
||||
[P2 배포] nats.conf + compose 기동, healthcheck healthy 확인
|
||||
@@ -117,7 +119,7 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2a (로컬 스
|
||||
▼
|
||||
[P4 전환] 드레인 → 잔여 스캔 → .mam.env 교체 (§9.5)
|
||||
▼
|
||||
[P5 검증] R-1 ~ R-10. R-5(retained) / R-9(계정 경계) 를 최종 관문으로
|
||||
[P5 검증] R-1 ~ R-13. R-5(retained) / R-9(계정 경계) / R-13(MQTT-over-WS)
|
||||
▼
|
||||
[P6 상시화] 로그 로테이션, JetStream 볼륨 백업, healthcheck 알림
|
||||
```
|
||||
@@ -173,10 +175,11 @@ M0 (문서 정합성) ──> M1 (Track 0 내결함성) ──> M2a (로컬 스
|
||||
- [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)
|
||||
- [x] 신규 가드 G-D5 ~ G-D9, G-R1, G-R2 구현 및 검증 (290 -> 297)
|
||||
- [x] P0.5: `docker/` 프로덕션 배포 자산 정본화 및 D-22 ~ D-30 회귀 가드 (297 -> 306)
|
||||
- [ ] 서버 배포 및 R-3 노출 면적 0 단언
|
||||
- [ ] §9.5 드레인·잔여 스캔 후 `.mam.env` 전환
|
||||
- [ ] R-1 ~ R-10 전건 통과 (R-5 / R-9 최종 관문)
|
||||
- [ ] R-1 ~ R-13 전건 통과 (R-5 / R-9 / R-13 최종 관문)
|
||||
|
||||
### M3: Track 2 보안 및 토픽 격리 (`A-2`, `B-16`)
|
||||
- [ ] G-11 무조건 `auth_token` 발급 적용
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pytest>=8.0
|
||||
PyYAML>=6.0
|
||||
@@ -383,7 +383,7 @@ def test_d16_private_server_nats_image_alpine_pinned():
|
||||
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"
|
||||
assert "alpine" in tag, f"Block #{i+1} nats image '{tag}' must use alpine variant"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -461,3 +461,235 @@ def test_d21_env_template_mqtt_var_coverage():
|
||||
assert "MQTT_MAX_RETRIES" not in line
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Track 1R / M2b — docker/ Canonical Deployment Assets Guards (D-22 ~ D-30)
|
||||
# ==============================================================================
|
||||
DOCKER_DIR = os.path.join(REPO_ROOT, "docker")
|
||||
COMPOSE_PATH = os.path.join(DOCKER_DIR, "docker-compose.yaml")
|
||||
NATS_CONF_PATH = os.path.join(DOCKER_DIR, "nats.conf")
|
||||
ENV_EXAMPLE_PATH = os.path.join(DOCKER_DIR, ".env.example")
|
||||
DOCKER_README_PATH = os.path.join(DOCKER_DIR, "README.md")
|
||||
|
||||
SECRET_VARS = {"MAM_BROKER_PASS", "MAM_OBSERVER_PASS",
|
||||
"HOME_BROKER_PASS", "SYS_BROKER_PASS"}
|
||||
|
||||
|
||||
def _load_compose():
|
||||
"""compose 파일을 dict 로. 파일이 비었거나 nats 서비스가 없으면 즉시 실패."""
|
||||
import yaml
|
||||
with open(COMPOSE_PATH, encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert isinstance(doc, dict) and doc.get("services"), (
|
||||
"docker/docker-compose.yaml is empty or has no services: — the docker/ "
|
||||
"assets were never populated")
|
||||
svc = doc["services"].get("nats")
|
||||
assert svc, "compose file defines no 'nats' service"
|
||||
return doc, svc
|
||||
|
||||
|
||||
def _active_conf_lines():
|
||||
"""nats.conf 에서 주석을 제외한 '활성' 라인만. 주석 템플릿을 오탐하지 않기 위함."""
|
||||
with open(NATS_CONF_PATH, encoding="utf-8") as f:
|
||||
lines = [ln.split("#", 1)[0].rstrip() for ln in f]
|
||||
return [ln for ln in lines if ln.strip()]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-22 — docker/ assets exist and are populated
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d22_docker_assets_exist_and_are_populated():
|
||||
for p in [COMPOSE_PATH, NATS_CONF_PATH, ENV_EXAMPLE_PATH, DOCKER_README_PATH]:
|
||||
assert os.path.exists(p), f"Required asset {p} does not exist"
|
||||
assert os.path.getsize(p) > 0, f"Required asset {p} is empty (0 bytes)"
|
||||
_load_compose()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-23 — compose image matches doc and is alpine
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d23_compose_image_matches_doc_and_is_alpine():
|
||||
_, svc = _load_compose()
|
||||
compose_img = svc.get("image", "")
|
||||
assert compose_img.startswith("nats:"), f"Expected nats image in compose, got {compose_img}"
|
||||
compose_tag = compose_img.split(":", 1)[1]
|
||||
assert compose_tag != "latest", "Compose image tag must not be 'latest'"
|
||||
assert "alpine" in compose_tag, f"Compose image tag '{compose_tag}' must use alpine variant"
|
||||
|
||||
doc_path = os.path.join(REPO_ROOT, "PRIVATE_SERVER.md")
|
||||
with open(doc_path, "r", encoding="utf-8") as f:
|
||||
doc_content = f.read()
|
||||
doc_tags = re.findall(r'\bnats:([a-zA-Z0-9_.-]+)', doc_content)
|
||||
assert doc_tags, "No nats image tags found in PRIVATE_SERVER.md"
|
||||
assert compose_tag in doc_tags, f"Compose image tag '{compose_tag}' not found in PRIVATE_SERVER.md"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-24 — compose port exposure contract
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d24_compose_port_exposure_contract():
|
||||
_, svc = _load_compose()
|
||||
ports = svc.get("ports", [])
|
||||
assert ports, "No ports exposed in nats service"
|
||||
|
||||
ctr_ports = set()
|
||||
for p in ports:
|
||||
parts = str(p).rsplit(":", 2)
|
||||
assert len(parts) == 3, f"Port mapping '{p}' must specify 3 parts (host:hostport:ctrport), bare mappings forbidden"
|
||||
ctr_ports.add(int(parts[2]))
|
||||
|
||||
assert ctr_ports == {1883, 4222, 8222, 8080}, f"Expected container ports {1883, 4222, 8222, 8080}, got {ctr_ports}"
|
||||
|
||||
p8222 = [p for p in ports if ":8222:8222" in str(p)]
|
||||
assert len(p8222) == 1, "Port 8222 mapping must exist"
|
||||
assert p8222[0] == "127.0.0.1:8222:8222", f"Port 8222 must be hardcoded to 127.0.0.1:8222:8222, got '{p8222[0]}'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-25 — secrets are fail closed
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d25_secrets_are_fail_closed():
|
||||
_, svc = _load_compose()
|
||||
env = svc.get("environment", {})
|
||||
if isinstance(env, list):
|
||||
env_dict = {}
|
||||
for item in env:
|
||||
k, v = item.split("=", 1)
|
||||
env_dict[k.strip()] = v.strip()
|
||||
env = env_dict
|
||||
|
||||
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
|
||||
conf_text = f.read()
|
||||
|
||||
active_text = "\n".join(_active_conf_lines())
|
||||
nats_vars = set(re.findall(r'\$([A-Z0-9_]+)', active_text))
|
||||
assert nats_vars, "No $VAR references found in nats.conf"
|
||||
assert nats_vars.issubset(set(env.keys())), f"nats.conf variables {nats_vars} not in compose environment {set(env.keys())}"
|
||||
|
||||
for k, v in env.items():
|
||||
assert "${" in v and ":?" in v, f"Environment variable '{k}={v}' must use '${{VAR:?error}}' fail-closed syntax"
|
||||
|
||||
with open(ENV_EXAMPLE_PATH, "r", encoding="utf-8") as f:
|
||||
example_text = f.read()
|
||||
|
||||
for svar in SECRET_VARS:
|
||||
assert svar in example_text, f"Secret variable '{svar}' missing from .env.example"
|
||||
|
||||
for line in example_text.splitlines():
|
||||
line = line.strip()
|
||||
for svar in SECRET_VARS:
|
||||
if line.startswith(f"{svar}="):
|
||||
val = line.split("=", 1)[1].strip()
|
||||
assert val == "", f"Secret variable '{svar}' in .env.example must have empty value, got '{val}'"
|
||||
|
||||
for match in re.finditer(r'password:\s*([^\s,}]+)', conf_text):
|
||||
pw_val = match.group(1).strip()
|
||||
assert pw_val.startswith("$"), f"nats.conf contains non-variable password value '{pw_val}'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-26 — nats_conf jetstream and mqtt contract
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d26_nats_conf_jetstream_and_mqtt_contract():
|
||||
_, svc = _load_compose()
|
||||
volumes = svc.get("volumes", [])
|
||||
target_data_vol = None
|
||||
for vol in volumes:
|
||||
parts = str(vol).split(":")
|
||||
if len(parts) >= 2 and parts[1] == "/data":
|
||||
target_data_vol = parts[1]
|
||||
assert target_data_vol == "/data", "Compose volume must mount to /data"
|
||||
|
||||
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
|
||||
conf_text = f.read()
|
||||
|
||||
store_dir_match = re.search(r'store_dir:\s*["\']?([^"\'\s,}]+)["\']?', conf_text)
|
||||
assert store_dir_match, "store_dir not found in nats.conf"
|
||||
assert store_dir_match.group(1).strip() == "/data", f"store_dir must be '/data', got '{store_dir_match.group(1)}'"
|
||||
|
||||
for key in ["max_file", "max_mem"]:
|
||||
m = re.search(rf'{key}:\s*([^\s,}}]+)', conf_text)
|
||||
assert m, f"{key} not found in nats.conf"
|
||||
assert re.match(r'^\d+[KMGT]$', m.group(1).strip()), f"{key} '{m.group(1)}' must match regex '^\\d+[KMGT]$'"
|
||||
|
||||
assert "mqtt {" in conf_text, "mqtt { block missing in nats.conf"
|
||||
assert "port: 1883" in conf_text or "port:1883" in conf_text, "mqtt port 1883 missing in nats.conf"
|
||||
assert "MAM:" in conf_text and "jetstream: enabled" in conf_text, "Account MAM must have 'jetstream: enabled'"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-27 — observer permissions match topic root
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d27_observer_permissions_match_topic_root():
|
||||
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("/", ".")
|
||||
|
||||
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
|
||||
conf_text = f.read()
|
||||
|
||||
subjects = re.findall(r'["\']([a-zA-Z0-9_.-]+(?:\.>|\.\*)?)["\']', conf_text)
|
||||
job_subjects = [s for s in subjects if "jobs" in s]
|
||||
assert job_subjects, "No job subjects found in nats.conf"
|
||||
for s in job_subjects:
|
||||
assert s.startswith(expected_prefix), f"Subject '{s}' in nats.conf does not match prefix '{expected_prefix}'"
|
||||
|
||||
assert "mam_observer" in conf_text, "mam_observer user missing in nats.conf"
|
||||
assert "deny:" in conf_text, "Publish deny permission missing for mam_observer in nats.conf"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-28 — healthcheck contract and image coupling
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d28_healthcheck_contract_and_image_coupling():
|
||||
_, svc = _load_compose()
|
||||
hc = svc.get("healthcheck")
|
||||
assert hc, "healthcheck block missing from nats service"
|
||||
|
||||
test_cmd = hc.get("test", [])
|
||||
test_str = " ".join(test_cmd) if isinstance(test_cmd, list) else str(test_cmd)
|
||||
assert "wget" in test_str, f"Healthcheck test '{test_str}' must use wget"
|
||||
assert "/healthz" in test_str, f"Healthcheck test '{test_str}' must target /healthz"
|
||||
assert "127.0.0.1:8222" in test_str, f"Healthcheck test '{test_str}' must use 127.0.0.1:8222"
|
||||
|
||||
img = svc.get("image", "")
|
||||
assert "alpine" in img, f"Image '{img}' must be alpine variant because healthcheck uses alpine wget"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-29 — env secrets never tracked
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d29_env_secrets_never_tracked():
|
||||
res_env = subprocess.run(["git", "check-ignore", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT)
|
||||
assert res_env.returncode == 0, "docker/.env must be ignored by .gitignore"
|
||||
|
||||
res_ex = subprocess.run(["git", "check-ignore", "docker/.env.example"], capture_output=True, text=True, cwd=REPO_ROOT)
|
||||
assert res_ex.returncode != 0, "docker/.env.example must NOT be ignored by .gitignore"
|
||||
|
||||
res_ls = subprocess.run(["git", "ls-files", "docker/.env"], capture_output=True, text=True, cwd=REPO_ROOT)
|
||||
assert res_ls.stdout.strip() == "", "docker/.env must never be tracked in git"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# D-30 — websocket origin policy is startable
|
||||
# --------------------------------------------------------------------------
|
||||
def test_d30_websocket_origin_policy_is_startable():
|
||||
active_lines = _active_conf_lines()
|
||||
active_text = "\n".join(active_lines)
|
||||
|
||||
assert "websocket {" in active_text, "websocket { block must exist in active nats.conf"
|
||||
assert "no_tls: true" in active_text or "no_tls:true" in active_text, (
|
||||
"Active nats.conf must specify 'no_tls: true' in websocket block (omission causes startup TLS error)"
|
||||
)
|
||||
|
||||
for line in active_lines:
|
||||
if "allowed_origins" in line:
|
||||
assert '"*"' not in line and "'*'" not in line, (
|
||||
f"allowed_origins must not contain '*' (NATS requires absolute URLs with http/https schemes, got '{line}')"
|
||||
)
|
||||
|
||||
with open(NATS_CONF_PATH, "r", encoding="utf-8") as f:
|
||||
full_conf = f.read()
|
||||
assert "/mqtt" in full_conf, "nats.conf must document /mqtt WebSocket MQTT path (N-7)"
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user