diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-810987f6.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-810987f6.md new file mode 100644 index 0000000..8efe44b --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-810987f6.md @@ -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://:8080/mqtt โ†’ MQTT. retained ์ข…๋ฃŒ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค. + # ws://: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..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: "", + 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:` == `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://:8080/")` | **ํ•ธ๋“œ์…ฐ์ดํฌ ์„ฑ๊ณต** (A-2 ์˜ ๋ฐ˜์ฆ ๊ฐ€๋Šฅ ํ˜•ํƒœ โ€” 403 ์ด ๋‚˜์˜ค๋ฉด M-19/M-20 ํŒ์ •์ด ํ‹€๋ฆฐ ๊ฒƒ) | +| **R-13** | MQTT-over-WS retained | ์žก ์ข…๋ฃŒ **ํ›„** MQTT.js ๋กœ `ws://: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) diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-93a74271.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-93a74271.md new file mode 100644 index 0000000..d5aae52 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-93a74271.md @@ -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] \ No newline at end of file diff --git a/PRIVATE_SERVER.md b/PRIVATE_SERVER.md index 650a151..492d75f 100644 --- a/PRIVATE_SERVER.md +++ b/PRIVATE_SERVER.md @@ -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//events`๋กœ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•˜๋ฉด, ์›น ๋ธŒ๋ผ์šฐ์ €๋‚˜ ํƒ€ ํ”„๋กœ์ ํŠธ์˜ NATS ๊ตฌ๋…์ž๋Š” NATS Subject `python.mqtt.jobs..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://: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) - ack_wait: 60s # WAN RTT ํก์ˆ˜ (๊ธฐ๋ณธ 30s) - max_ack_pending: 1024 # ๊ธฐ๋ณธ 100 โ†’ ๋‹ค์ค‘ ์—์ด์ „ํŠธ ๋™์‹œ ๋ฐœํ–‰ ์—ฌ์œ  + port: 1883 + ack_wait: 60s # WAN RTT ํก์ˆ˜ (๊ธฐ๋ณธ 30s) + 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://:8080/mqtt โ†’ MQTT. retained ์ข…๋ฃŒ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค. + # ws://:8080/ โ†’ NATS ๋„ค์ดํ‹ฐ๋ธŒ. retained ๋ฅผ ๋ฐ›์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค (N-1). + # ๋ธŒ๋ผ์šฐ์ € ๋Œ€์‹œ๋ณด๋“œ๋Š” MQTT.js ๋กœ /mqtt ์— ๋ถ™์ด๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. } -# โ”€โ”€ ์ธ์ฆ ๋ฐ ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ (Rev.2: C1/C2 ๋ฐ˜์˜) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ ์ธ์ฆ ๋ฐ ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ accounts { MAM: { - jetstream: enabled # MQTT ๋‚ด๋ถ€ ์ŠคํŠธ๋ฆผ์ด ์ด ๊ณ„์ • ์•ˆ์— ์ƒ์„ฑ๋จ + 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..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 < .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) diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..a7e995f --- /dev/null +++ b/docker/.env.example @@ -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 ๊ณ ์ •). diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..a19938c --- /dev/null +++ b/docker/README.md @@ -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= +``` + +### (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: "", + protocolVersion: 4, // MQTT 3.1.1 +}); + +client.subscribe("python/mqtt/jobs/+/events"); +// ์ด ํด๋ผ์ด์–ธํŠธ๋Š” nats-server ๋‚ด๋ถ€์—์„œ ์™„์ „ํ•œ MQTT ํด๋ผ์ด์–ธํŠธ๋กœ ์ทจ๊ธ‰๋˜๋ฏ€๋กœ +// ์žก์ด ๋๋‚œ ๋’ค์— ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์ƒˆ๋กœ๊ณ ์นจํ•ด๋„ retained ์ตœ์ข… ์ด๋ฒคํŠธ๋ฅผ ์ฆ‰์‹œ ์ˆ˜์‹ ํ•ฉ๋‹ˆ๋‹ค. + +// ์ฐธ๊ณ  (nats.ws): +// ws://: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 -p 1883 -t test -m hi` | Connect return code 5 (Not authorized) | +| **R-6** | ์ธ์ฆ ์„ฑ๊ณต (์ •์ƒ) | `mosquitto_pub -h -p 1883 -u mam_agent -P -t test -m hi` | ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰ ์„ฑ๊ณต (rc=0) | +| **R-7** | Retained ์ด๋ฒคํŠธ ์ „๋‹ฌ | `mosquitto_sub -h -p 1883 -u mam_agent -P -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://:8080/mqtt` ๋กœ MQTT.js ํด๋ผ์ด์–ธํŠธ๋กœ ์ ‘์†ํ•˜์‹ญ์‹œ์˜ค (N-7). | diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..ce5a3d3 --- /dev/null +++ b/docker/docker-compose.yaml @@ -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: diff --git a/docker/nats.conf b/docker/nats.conf new file mode 100644 index 0000000..ebd9006 --- /dev/null +++ b/docker/nats.conf @@ -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://:8080/mqtt โ†’ MQTT. retained ์ข…๋ฃŒ ์ด๋ฒคํŠธ๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค. + # ws://: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..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 diff --git a/implementation_plan.md b/implementation_plan.md index ce8f288..68d2fff 100644 --- a/implementation_plan.md +++ b/implementation_plan.md @@ -107,19 +107,21 @@ M0 (๋ฌธ์„œ ์ •ํ•ฉ์„ฑ) โ”€โ”€> M1 (Track 0 ๋‚ด๊ฒฐํ•จ์„ฑ) โ”€โ”€> M2a (๋กœ์ปฌ ์Šค ## 5. Track 1R: ์›๊ฒฉ ํ”„๋กœ๋•์…˜ ์ „ํ™˜ ๋กœ๋“œ๋งต (M2b ์ƒ์„ธ) ``` -[P0 ๊ต์ •] D-1~D-5 ๋ฌธ์„œ ๊ต์ • + ยง5.2 ๊ฒฝ๊ณ„ ๋ช…๋ฌธํ™” + ์‹ ๊ทœ ๊ฐ€๋“œ 7์ข… +[P0 ๊ต์ •] D-1~D-5 ๋ฌธ์„œ ๊ต์ • + ยง5.2 ๊ฒฝ๊ณ„ ๋ช…๋ฌธํ™” + ์‹ ๊ทœ ๊ฐ€๋“œ 7์ข… โ–ผ -[P1 ์„œ๋ฒ„ ์ค€๋น„] VPS/ํ™ˆ๋žฉ ํ”„๋กœ๋น„์ €๋‹, Docker, Tailscale ๊ฐ€์ž… +[P0.5 ์ž์‚ฐํ™”] docker/ 4๋Œ€ ์ž์‚ฐ ์ •๋ณธํ™” + D-22~D-30 ํšŒ๊ท€ ๊ฐ€๋“œ (297 -> 306) โ–ผ -[P2 ๋ฐฐํฌ] nats.conf + compose ๊ธฐ๋™, healthcheck healthy ํ™•์ธ +[P1 ์„œ๋ฒ„ ์ค€๋น„] VPS/ํ™ˆ๋žฉ ํ”„๋กœ๋น„์ €๋‹, Docker, Tailscale ๊ฐ€์ž… โ–ผ -[P3 ์ž ๊ธˆ] ๋ฐ”์ธ๋“œ ์ฃผ์†Œ ํ•œ์ • + UFW + ss/nmap ๋กœ ๋…ธ์ถœ ๋ฉด์  0 ๋‹จ์–ธ (R-3) +[P2 ๋ฐฐํฌ] nats.conf + compose ๊ธฐ๋™, healthcheck healthy ํ™•์ธ โ–ผ -[P4 ์ „ํ™˜] ๋“œ๋ ˆ์ธ โ†’ ์ž”์—ฌ ์Šค์บ” โ†’ .mam.env ๊ต์ฒด (ยง9.5) +[P3 ์ž ๊ธˆ] ๋ฐ”์ธ๋“œ ์ฃผ์†Œ ํ•œ์ • + UFW + ss/nmap ๋กœ ๋…ธ์ถœ ๋ฉด์  0 ๋‹จ์–ธ (R-3) โ–ผ -[P5 ๊ฒ€์ฆ] R-1 ~ R-10. R-5(retained) / R-9(๊ณ„์ • ๊ฒฝ๊ณ„) ๋ฅผ ์ตœ์ข… ๊ด€๋ฌธ์œผ๋กœ +[P4 ์ „ํ™˜] ๋“œ๋ ˆ์ธ โ†’ ์ž”์—ฌ ์Šค์บ” โ†’ .mam.env ๊ต์ฒด (ยง9.5) โ–ผ -[P6 ์ƒ์‹œํ™”] ๋กœ๊ทธ ๋กœํ…Œ์ด์…˜, JetStream ๋ณผ๋ฅจ ๋ฐฑ์—…, healthcheck ์•Œ๋ฆผ +[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` ๋ฐœ๊ธ‰ ์ ์šฉ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..40ddeab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest>=8.0 +PyYAML>=6.0 diff --git a/tests/test_deploy_freshness.py b/tests/test_deploy_freshness.py index 19f189f..fcbca41 100644 --- a/tests/test_deploy_freshness.py +++ b/tests/test_deploy_freshness.py @@ -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)" + + +