Files
multi-agent-mux/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-810987f6.md
T

48 KiB
Raw Blame History

📐 구현 계획서 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:

	// 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:

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):

	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 를 비우지 않는 순간 listEmptyfalse 가 되어 원점 검사가 켜집니다. "모두 허용"을 의도한 설정이 "검사 활성화"를 유발하는 구조입니다.

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 브라우저 대시보드)를 측정하다 확인한 사실입니다.

// 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.22busybox 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 lookupVariableparseEnv(...) 환경변수 값이 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 && !NoTLSwebsocket 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.shPRIVATE_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

# ==============================================================================
# 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

# ==============================================================================
# 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

# ==============================================================================
# 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 .envopenssl rand -base64 32 ×4 → chmod 600 .envdocker compose up -ddocker 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 반영, 필수 신설:

// 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 기본값 .envMQTT_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 공통 헬퍼

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_observerpublish 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
82220.0.0.0:8222:8222 로 변경 D-24
.env.exampleMAM_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/ 에 정본 파일로 존재하며 아래 펜스는 그 사본입니다. 어긋나면 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.ymldocker/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/ -q306건 전건 통과.
  3. §4.3 뮤테이션 12건이 각각 지정된 가드를 FAIL 시킴이 로그로 확인된다.
  4. T-1 ~ T-5 문서 동기화가 동일 커밋에 포함된다.
  5. git statusdocker/.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.txtpytest>=8.0 단 한 줄인데 tests/test_sanity.py:3 은 최상단에서 import yaml 합니다. 현재 venv 에는 설치되어 있어 드러나지 않지만, requirements.txt 만으로 새 venv 를 만들면 수집 단계에서 다수 파일이 에러납니다. D-22 ~ D-28 이 yaml 의존을 더 깊게 만듭니다. 처방: requirements.txtPyYAML>=6.0 추가. (pytest.importorskip 은 부적절 — 스킵되면 배포 가드가 조용히 사라집니다.)

N-3 (P2) — D-16 가드의 구멍: nats:2.12 가 통과한다

tests/test_deploy_freshness.py:387assert "-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.golookupVariable 은 환경변수를 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 전용

mqttSendRetainedMsgsToNewSubsmqttPacketSub 핸들러에서만 호출되고 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.envMQTT_PASSWORD ↔ 서버 docker/.envMAM_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 d22FAIL 하는지 먼저 확인
  • requirements.txtPyYAML>=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/ -q306 passed
  • §4.3 뮤테이션 12건 각각 FAIL 확인 후 원복, 로그 첨부
  • git statusdocker/.env 부재 확인
  • ⚠️ allowed_origins: ["*"] 를 활성 설정으로 넣지 말 것 — 브로커가 기동하지 못합니다 (§A-3, M-22)