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

41 KiB

🌐 원격 서버 nats-server Docker 프로덕션 배포 계획서 Rev.2 (Job b11d499d)

  • 역할: Planner (MULTI_AGENT_RULES.md §1 — 저장소 코드/문서 미변경, 계획서만 산출)
  • 선행 리비전: Rev.1 = Job 27236ab6
  • 반영 챌린지: Job 9a5cb88f (agy) — [VERDICT: PASS WITH CHALLENGE]
  • 기준 커밋: c6b6c77 (Track 0 완료, 290 tests collected 실측)
  • 검증 원칙: 추론이 아닌 실측. 챌린지는 지시가 아니라 가설로 취급하여 재현·반증했습니다.

0. Rev.2 판정 요약 (Adjudication)

챌린지 판정 요지
C1 계정 격리가 교차 관측을 차단 🟡 부분 인용 — 진단 유효, 귀속 부정확, 두 옵션 모두 결정적 한계 누락 계정 격리 사실은 맞음. 다만 "계획이 HOME 계정에서 MAM 이벤트 관측을 주장한다"는 귀속은 부정확 — PRIVATE_SERVER.md:230정반대를 이미 처방. 반면 내 Rev.1 config에 관측자 사용자가 아예 없었던 것은 실제 결함이므로 수용. 신규 실측: retained 이벤트는 MQTT 구독자에게만 전달되므로 Option A/B 어느 쪽도 "사후 접속 대시보드가 종료 이벤트를 본다"를 만들지 못함
C2 _load_dotenv 우선순위 🟢 방향 수용 + 근본 결함 재정의 + 신규 결함 1건 발견 진짜 결함은 후보 목록이 아니라 단일 후보 해석. 그리고 MAM_ENV_FILE이 없는 파일을 가리키면 다른 후보를 하나도 시도하지 않고 공개 브로커로 폴백(실측) — 제안된 순서로는 고쳐지지 않음
C3 G-D5 스코핑 🟡 이미 Rev.1에 존재. 단 잔여 지적이 D-1을 강화 펜스 한정·오탐 부재 단언은 Rev.1 §6에 이미 명시. 신규 실측: NATS 렉서는 인용되지 않은 값에서만 $VAR를 해석 → store_dir: "$HOME/..."는 리터럴이며 인용 heredoc과 결합 시 D-1과 동일하게 파손. G-D5를 2항 검사로 강화

Rev.2 실질 변경 6건

  1. §A-1에 mam_observer 읽기 전용 사용자를 명시적으로 추가(Option B 채택 — 저장소 §5.5 처방과 일치).
  2. retained는 MQTT 전용이라는 신규 실측을 §1.9로 신설하고 §5.2 브리징 주장의 경계로 명문화.
  3. Option A(export/import)를 예외 경로로 문법 검증까지 마쳐 부록에 배치(무조건 채택하지 않는 근거 3건 첨부).
  4. B-17 처방을 first-hit-wins 후보 목록으로 재정의하고 MAM_ENV_FILE 조기 탈출 결함을 추가.
  5. G-D5를 2항 검사로 강화(값 + heredoc 구분자), $VAR 인용 규칙을 §1.1 각주에 정밀화.
  6. G-D9 신설 — 문서 config 예제의 subject 리터럴과 DEFAULT_TOPIC_ROOT 일치 강제(M3 토픽 전환 시 조용한 파손 차단). 테스트 전망 290 → 297 → 298.

1. 사전 실측 결과 (Pre-Flight Measurements)

Rev.1의 D-1 ~ D-5, H-1 ~ H-3은 챌린저가 "Verified 100% accurate"로 승인했습니다. 아래는 요지 유지 + Rev.2 신규 실측 2건(§1.9, §1.10) 및 §1.1 각주 정밀화입니다.

1.1 D-1 — store_dir~는 확장되지 않는다 (P1)

PRIVATE_SERVER.md:73, :146store_dir: "~/.local/share/nats/data"를 지시합니다.

증거 1 — NATS 설정 파서에 틸드 확장 없음 (server/opts.go):

case "store", "store_dir", "storedir":
    opts.StoreDir = mv.(string)      // 문자열 그대로 대입. os.UserHomeDir 호출 없음

증거 2 — 인용 heredoc이 셸 확장까지 차단 (실측): <<'EOF'store_dir: "~/.local/share/nats/data" 리터럴 유지 / <<EOF/Users/godopu16/.local/share/nats/data 전개. 리터럴 ~ 경로에 mkdir -p → CWD 아래 ./~ 디렉터리 생성.

영향: JetStream 스토리지가 ./~/.local/share/nats/data에 생성됩니다. MQTT의 $MQTT_rmsgs(retained)·$MQTT_sess(세션)가 여기 있으므로, 다른 CWD에서 재기동하면 retained 종료 이벤트가 통째로 사라집니다.

Important

각주 정밀화 (Rev.2, C3 파생): NATS 설정의 $VAR 참조는 인용되지 않은 값에서만 해석됩니다. 렉서 원문 — "Check if the unquoted string is a variable reference, starting with $." 이며 lexQuotedString"It will not interpret any internal contents." 입니다. 따라서 store_dir: "$HOME/..."는 리터럴 문자열이며, 인용 heredoc과 결합하면 ./$HOME/.local/share/nats/data가 만들어져 D-1과 동일하게 파손됩니다. 규칙: $HOME셸이 전개할 때만(= 비인용 heredoc 안에서만) 허용. NATS가 해석해야 하는 변수(password: $MAM_BROKER_PASS)는 따옴표를 씌우지 않습니다.

1.2 D-2 — nats:latest는 scratch 변형이라 healthcheck를 넣을 수 없다 (P1)

docker-library/official-imageslibrary/nats: SharedTags: 2.14.5, 2.14, 2, latest @ Directory: 2.14.x/scratch. 해당 Dockerfile은 FROM scratch + ENTRYPOINT ["/nats-server"]. → 셸·wget·curl 부재로 healthcheck 구현 불가, 게다가 메이저 경계를 넘나드는 부동 태그.

교정: image: nats:2.12-alpine. alpine 엔트리포인트가 첫 인자 - 감지 시 nats-server를 자동 prepend하므로 command: ["-c", ...] 라인은 양쪽 변형에서 동일 동작(실측):

if [ "$#" -eq 0 ] || [ "${1#-}" != "$1" ]; then set -- nats-server "$@"; fi

1.3 D-3 — 무인증 모니터링 포트를 전 인터페이스에 게시 (P1)

현행 PRIVATE_SERVER.md:120-124"8222:8222", "8080:8080"을 0.0.0.0에 게시합니다. NATS 공식 문서: "The monitoring port is unauthenticated by default."/varz·/connz·/jsz·/routez 공개. 8080은 no_tls: true 평문.

1.4 D-4 — TLS 사용 시 호스트명 검증이 강제된다 (P1)

make_client()tls_set(...)만 호출하고 tls_insecure_set()을 부르지 않습니다. 실측(paho 2.1.0): check_hostname=True, verify_mode=CERT_REQUIRED, _tls_insecure=False, 우회 env 없음.

시나리오 결과
A) IP 호스트 + 정확한 CA 번들 핀 IP address mismatch, certificate is not valid for '127.0.0.1'
B) 사설 CA + MQTT_CA_CERTS 미설정 self signed certificate
C) MQTT_TLS=0으로 TLS 포트 접속 CONNECTED (handshake ok) ← 소켓만 열림

→ (1) TLS 시 MQTT_BROKER인증서 SAN의 DNS 이름 필수(IP 금지). (2) 사설 CA면 MQTT_CA_CERTS 필수, Let's Encrypt면 비워 둘 것. (3) 소켓 연결 성공은 브로커 정상의 증거가 아님 — 검증은 CONNACK 또는 /healthz까지 도달해야 함.

1.5 D-5 — 환경변수 템플릿 양방향 드리프트 (P2)

deploy/install.sh:521-522MQTT_RETRY_INTERVAL=2, MQTT_MAX_RETRIES=5.mam.env활성 기본값으로 기록하지만 읽는 코드 0건. 실제 재시도는 --attempts(기본 3) + with_retry(base_delay=0.5, factor=2.0, max_delay=8.0). 역으로 코드가 읽는 MQTT_KEEPALIVE(기본 60)는 .mam.env.example0건.

1.6 H-1 — freeze 경로의 조용한 공개 브로커 회귀 (P1)

_load_dotenv()__file__에서 위로 올라가다 .agents 또는 .git에서 멈춥니다. freeze 스냅샷 루트는 .agents만 담고 .mam.env는 없습니다(실측: ls 결과 .agents 단 하나).

# 조건 해석된 브로커
1 저장소 경로 스크립트, MAM_ENV_FILE 없음 nats.example.internal:8883 tls=True
2 freeze 경로, MAM_ENV_FILE 없음 broker.hivemq.com:1883 tls=False
3 freeze 경로 + MAM_ENV_FILE nats.example.internal:8883 tls=True

정상 루프는 run_loop.sh:106export MAM_ENV_FILE=...으로 보호되나, 위임 브리프가 배포하는 명령줄은 freeze 경로를 직접 가리킵니다.

1.7 H-2 — 잡 레코드가 브로커를 핀 고정 (P1)

registry.py:73-74가 등록 시점 브로커 블록을 스냅샷하고 broker_config_from_job()이 env보다 우선 적용합니다. → .mam.env 교체만으로는 기존 pending/running 잡이 전환되지 않습니다.

1.8 H-3 — 비밀번호 평문 보관 / 자동 토큰 발급 이득 (P2)

레코드에 "password": "SUPERSECRET123" 평문 확인(모드 0600, .gitignore:14.mam/). 동시에 tls 또는 username 감지 시 auth_token 자동 발급 확인 → 원격 인증 전환이 곧 HMAC 자동 활성화이며 B-16/G-11 위험을 대부분 부수 해소.

1.9 🆕 N-1 — retained 메시지는 MQTT 구독자에게만 전달된다 (P1, C1 파생 신규 실측)

챌린저의 C1은 계정 경계만 다뤘으나, 계정 문제를 어떻게 풀든 바뀌지 않는 더 근본적인 경계가 있습니다.

server/mqtt.go 실측 — retained 전달은 MQTT SUBSCRIBE 처리 경로에서만 호출됩니다:

case mqttPacketSub:                       // ← MQTT SUBSCRIBE 패킷 처리
    ...
    c.mqttEnqueueSubAck(pi, filters)
    c.mqttSendRetainedMsgsToNewSubs(subs) // ← 여기서만 호출

func (c *client) mqttSendRetainedMsgsToNewSubs(subs []*subscription) {
    for _, sub := range subs {
        if sub.mqtt != nil && sub.mqtt.prm != nil { ... }   // ← MQTT 구독에만 존재하는 필드
    }
}

결론: NATS 네이티브 구독자와 WebSocket(NATS) 구독자는 retained 메시지를 절대 받지 못합니다. 계정을 합치든(Option B), export/import를 걸든(Option A) 이 사실은 변하지 않습니다.

MAM에 주는 구체적 의미:

  • publish_event.pyretain = args.retained or args.event in TERMINAL_EVENTS — 즉 종료 이벤트가 정확히 retained 대상입니다.
  • 잡이 끝난 뒤에 접속한 NATS/WebSocket 대시보드는 그 잡의 종료 이벤트를 보지 못합니다. 라이브 스트리밍만 가능합니다.
  • PRIVATE_SERVER.md §5.2의 "즉시 실시간 수신" 주장은 라이브 구간에 한정해야 정확합니다.

대시보드가 사후 상태까지 알아야 한다면 선택지는 2개뿐:

  1. 대시보드를 MQTT로 붙인다(같은 nats-server의 1883 리스너 사용, retained 그대로 수신).
  2. §5.3의 JetStream 리플레이 스트림을 옵트인한다(python.mqtt.jobs.> 구독 스트림 + max_age/max_bytes 상한 필수).

이 두 갈래를 §5.2 개정안과 §A-1 주석에 명시합니다.

1.10 🆕 H-4 — MAM_ENV_FILE이 없는 파일을 가리키면 모든 폴백이 무력화된다 (P1, C2 파생 신규 실측)

_load_dotenv() 도입부:

explicit_file = os.environ.get("MAM_ENV_FILE")
if explicit_file:
    if os.path.isfile(explicit_file):
        _parse_env_file(explicit_file)
    return                      # ← 파일이 없어도 여기서 종료. 다른 후보를 시도하지 않음

실측:

조건 해석된 브로커
MAM_ENV_FILE=<오타/이동된 경로>, 저장소 경로 스크립트 broker.hivemq.com tls=False
MAM_ENV_FILE=<정상 경로> (대조군) nats.private.internal tls=True
freeze 경로 스크립트, cwd = 실제 저장소, env 없음 broker.hivemq.com tls=False

중요: 챌린저가 제안한 우선순위 재배열(MAM_ENV_FILE → MAM_REAL_ROOT → …)은 이 경로를 고치지 못합니다. 1순위에서 이미 return으로 탈출하기 때문입니다. 근본 결함은 순서가 아니라 단일 후보 해석입니다(§8 B-17 재정의).

세 번째 행은 챌린저의 os.getcwd() 도입 근거가 실측으로 타당함을 보여줍니다.


2. §A — PRIVATE_SERVER.md 개정안: §9 「원격 서버 프로덕션 배포」 신설

A-1. 프로덕션 nats.conf (Rev.2 — 관측자 사용자 추가)

# nats.conf — 원격 프로덕션 (컨테이너 내부 절대경로 기준)
server_name: mam-hub

# ── JetStream: MQTT retained/QoS1 저장소. MAM 종료 이벤트 재수신이 여기에 의존 ──
jetstream {
  store_dir: "/data"          # D-1: 절대경로. '~' 도, 인용된 "$HOME" 도 확장되지 않음
  max_file: 10G
  max_mem: 256M
}

http_port: 8222               # D-3: 호스트 게시는 loopback 한정 (§A-3)

mqtt {
  port: 1883                  # 공개 TLS 모델에서는 8883 + tls 블록 (§B-3)
  ack_wait: 60s               # WAN RTT 흡수 (기본 30s)
  max_ack_pending: 1024       # 기본 100 → 다중 에이전트 동시 발행 여유
}

websocket {
  port: 8080
  no_tls: true                # 사설망/tailnet 한정
}

# ── 인증 및 멀티테넌시 (Rev.2: C1 반영) ────────────────────────────────
accounts {
  MAM: {
    jetstream: enabled                      # MQTT 내부 스트림이 이 계정 안에 생성됨
    users: [
      # 발행자 겸 구독자 — MAM 에이전트 본체
      { user: mam_agent, password: $MAM_BROKER_PASS }

      # 관측자 — 대시보드/모니터링. PRIVATE_SERVER.md §5.5 의 '동일 계정 배치' 처방
      { user: mam_observer, password: $MAM_OBSERVER_PASS,
        permissions: {
          subscribe: { allow: ["python.mqtt.jobs.>"] }   # M3 이후: "mam.<fp>.jobs.>"
          publish:   { deny:  [">"] }
        }
      }
    ]
  }
  # MAM 과 무관한 홈랩 서비스 전용. MAM subject 는 보이지 않음(의도된 격리)
  HOME: { jetstream: enabled, users: [ { user: home, password: $HOME_BROKER_PASS } ] }
  SYS:  { users: [ { user: sys, password: $SYS_BROKER_PASS } ] }
}
system_account: SYS

Important

관측자는 반드시 MAM 계정 안에 둡니다. NATS 계정은 하드 격리 경계이므로 user: home(계정 HOME)으로 접속한 클라이언트는 python.mqtt.jobs.>를 구독해도 0건을 받습니다. 이는 PRIVATE_SERVER.md:230 §5.5가 이미 처방한 배치("MAM과 이를 관측하는 대시보드는 동일한 계정(MAM)에 배치")와 정확히 일치합니다. 서로 다른 신뢰 도메인이라 계정을 반드시 갈라야 하는 예외 상황은 부록 X(Option A) 를 따르십시오.

Warning

관측자가 받는 것과 받지 못하는 것 (§1.9 N-1)

  • 잡 실행 발생하는 모든 이벤트 (라이브 스트리밍)
  • 이미 끝난 잡의 retained 종료 이벤트 — retained 는 MQTT 구독자에게만 전달됩니다. NATS/WebSocket 대시보드는 접속 이전 상태를 재구성하지 못합니다.
  • 사후 상태가 필요하면 대시보드를 MQTT(1883)로 붙이거나 §5.3의 JetStream 리플레이 스트림을 옵트인하십시오.

Note

계정별 JetStream 활성화는 필수입니다. MQTT는 접속 계정 안에 $MQTT_sess·$MQTT_rmsgs·$MQTT_out 스트림을 만듭니다. 계정에 JetStream이 없으면 JetStream not enabled for account(ErrCode 10039, HTTP 503)로 실패합니다. 전역 요건도 별도로 존재합니다 — mqtt requires JetStream to be enabled if running in standalone mode (mqtt.go:234). 다계정 구성에서의 계정별 요구는 스트림 생성 경로로부터의 추론이며, 위 처방은 fail-safe입니다. M2b 스파이크 R-4에서 확인 항목으로 지정합니다.

A-2. 프로덕션 docker-compose.yml

services:
  nats:
    image: nats:2.12-alpine          # D-2: latest(=scratch)는 healthcheck 불가
    container_name: mam-nats
    restart: unless-stopped
    command: ["-c", "/etc/nats/nats.conf"]
    environment:
      # 미해결 $VAR 는 파싱 에러 → 시크릿 누락 시 '기동 실패'로 fail-closed
      MAM_BROKER_PASS:   ${MAM_BROKER_PASS:?set in .env}
      MAM_OBSERVER_PASS: ${MAM_OBSERVER_PASS:?set in .env}
      HOME_BROKER_PASS:  ${HOME_BROKER_PASS:?set in .env}
      SYS_BROKER_PASS:   ${SYS_BROKER_PASS:?set in .env}
    ports:
      - "${MQTT_BIND:-127.0.0.1}:1883:1883"
      - "127.0.0.1:8222:8222"                  # D-3: 무인증 모니터링은 loopback 한정
      - "${WS_BIND:-127.0.0.1}:8080:8080"
    volumes:
      - ./nats.conf:/etc/nats/nats.conf:ro
      - nats-data:/data
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

volumes:
  nats-data:

Warning

Docker의 published 포트는 UFW를 우회합니다. Docker가 삽입하는 NAT/FORWARD 규칙이 ufw의 INPUT 체인보다 먼저 평가되므로, ufw deny 1883을 걸어도 -p 1883:1883으로 게시한 포트는 인터넷에 열립니다. 본 계획이 방화벽 대신 published 포트 자체에 바인드 주소를 명시하는 이유입니다.

A-3. 포트 노출 매트릭스

포트 용도 Tailscale 모델 (권장) 공개 TLS 모델 절대 금지
1883 MQTT 평문 tailnet IP 바인드 ✖ 미게시 0.0.0.0 게시
8883 MQTT TLS (불필요) 0.0.0.0 + LE 인증서 인증 없이 게시
4222 NATS 네이티브 tailnet IP 바인드 미게시(또는 TLS+인증) 0.0.0.0 평문
8222 HTTP 모니터링 127.0.0.1 한정 127.0.0.1 한정 어떤 경우에도 공개
8080 WebSocket(no_tls) tailnet IP 바인드 미게시 공개 인터페이스 게시

A-4. 🆕 PRIVATE_SERVER.md §5.2 개정 (N-1 반영)

기존 §5.2 문장 "웹 브라우저나 타 프로젝트의 NATS 구독자는 … 즉시 실시간 수신할 수 있습니다" 에 다음 경계를 병기합니다.

- **경계 (필수 인지)**: 교차 프로토콜 브리징은 **라이브 스트리밍에 한정**됩니다. MQTT의
  retained 메시지는 MQTT 구독자에게만 전달되므로(`mqttSendRetainedMsgsToNewSubs`가
  MQTT SUBSCRIBE 경로 전용), 잡이 끝난 뒤 접속한 NATS/WebSocket 대시보드는 그 잡의
  **종료 이벤트를 수신하지 못합니다**. 사후 상태가 필요하면 (a) 대시보드를 MQTT(1883)로
  연결하거나 (b) §5.3 JetStream 리플레이 스트림을 옵트인하십시오.
- **계정 배치**: 관측 클라이언트는 §5.5 처방대로 `MAM` 계정 안의 읽기 전용 사용자
  (`mam_observer`)로 접속해야 합니다. 다른 계정에서는 subject 가 보이지 않습니다.

3. §B — 원격 네트워킹 & 보안 가이드

B-1. 노출 모델 3안 비교 및 권고

항목 🏆 모델 T: Tailscale/WireGuard 오버레이 모델 P: 공개 TLS (Let's Encrypt) 모델 S: SSH 터널
인터넷 노출 면적 0 8883 1개 0
인증서 필요 불필요 (MQTT_TLS=0) 필수 + 90일 갱신 불필요
D-4 호스트명 제약 해당 없음 도메인 필수, IP 불가 해당 없음
도메인 필요 불필요 필수 불필요
이동성 자동 자동 터널 수동 관리
장애 지점 tailnet 코디네이터 certbot 갱신 실패 SSH 세션

권고: 모델 T. 근거 — (1) D-4의 도메인·SAN 제약을 소거, (2) D-3의 모니터링/WS 노출을 구조적으로 제거, (3) 롤백이 .mam.env 한 줄, (4) MAM은 관측 사이드카이므로(제어 평면은 wait_for_job 파일 폴링) 오버레이 지연이 오케스트레이션 정확성에 영향을 주지 않음.

B-2. 방화벽 (UFW) — 2차 방어선

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp

# 모델 T: tailnet 인터페이스만 허용
sudo ufw allow in on tailscale0 to any port 1883 proto tcp
sudo ufw allow in on tailscale0 to any port 4222 proto tcp
sudo ufw allow in on tailscale0 to any port 8080 proto tcp

# 모델 P: 8883만 공개
# sudo ufw allow 8883/tcp
# sudo ufw allow 80/tcp          # certbot HTTP-01 챌린지 기간 한정

sudo ufw enable && sudo ufw status verbose

Docker 우회 대응(필수) — compose 옆 .env에 바인드 주소를 주입하고 실제 바인딩을 단언합니다:

MQTT_BIND=100.x.y.z          # tailscale ip -4
WS_BIND=100.x.y.z
sudo ss -lntp | grep -E ':(1883|4222|8222|8080)\b'
# 기대: 8222 는 127.0.0.1 에만, 1883/8080 은 tailnet IP 에만

B-3. 모델 P 전용 — TLS / Certbot

sudo certbot certonly --standalone -d mam-broker.example.com

# nats.conf 의 mqtt 블록:
#   mqtt {
#     port: 8883
#     tls {
#       cert_file: "/etc/letsencrypt/live/mam-broker.example.com/fullchain.pem"
#       key_file:  "/etc/letsencrypt/live/mam-broker.example.com/privkey.pem"
#     }
#   }
# compose 볼륨: live/ 는 archive/ 로의 심볼릭 링크 → /etc/letsencrypt 전체를 마운트
#   - /etc/letsencrypt:/etc/letsencrypt:ro

sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nats.sh >/dev/null <<'SH'
#!/bin/sh
docker compose -f /srv/mam-nats/docker-compose.yml kill -s HUP nats
SH
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nats.sh
sudo certbot renew --dry-run

클라이언트 규칙 (D-4): MQTT_BROKER도메인(IP 금지). Let's Encrypt 사용 시 MQTT_CA_CERTS설정하지 않음. 사설 CA일 때만 지정하고 SAN에 접속명을 반드시 포함.

B-4. 사용자 인증 및 시크릿 주입

openssl rand -base64 32          # 계정/사용자별로 각각 생성 (mam_agent, mam_observer, home, sys)
chmod 600 .env

NATS 파서는 미해결 $VAR에러로 처리하므로 시크릿이 비면 서버가 조용히 익명으로 뜨지 않고 기동에 실패합니다(fail-closed, 의도적 채택).

H-3 완화 규칙: (1) MQTT_PASSWORD는 사람이 재사용하는 암호가 아니라 기계 생성 토큰만 사용(레코드에 평문으로 남음). (2) 회전 시 서버 .envdocker compose up -d → 클라이언트 .mam.env잔여 잡 레코드 정리(§C-3과 동일 절차). (3) 장기 과제는 B-18.

권한 격리: mam_agent는 발행/구독, mam_observerpublish: { deny: [">"] }발행 전면 금지. 이는 A-2(외부 악의적 이벤트 주입) 대응과 같은 방향이며, HMAC(auth_token)과 이중 방어를 이룹니다.


4. §C — 클라이언트 설정 및 원격 검증 플레이북

C-1. .mam.env (모델별)

# ── 모델 T (Tailscale, 권장) ──────────────────────────────────
MQTT_BROKER="mam-hub.tailXXXX.ts.net"   # 또는 100.x.y.z (평문이므로 IP 가능)
MQTT_PORT=1883
MQTT_TLS=0
MQTT_USERNAME=mam_agent
MQTT_PASSWORD=<기계 생성 토큰>
MQTT_KEEPALIVE=60                        # D-5: 코드가 실제로 읽는 값. 템플릿에 추가 필요

# ── 모델 P (공개 TLS) ────────────────────────────────────────
# MQTT_BROKER="mam-broker.example.com"   # D-4: 인증서 SAN 의 DNS 이름. IP 금지
# MQTT_PORT=8883
# MQTT_TLS=1
# MQTT_CA_CERTS 는 Let's Encrypt 사용 시 '설정하지 않음'

D-5 교정: MQTT_RETRY_INTERVAL / MQTT_MAX_RETRIES는 어떤 코드도 읽지 않습니다. 템플릿에서 제거하거나 "미사용(historical)"로 강등하고, 재시도 조정은 publish_event.py --attempts임을 명시. 역으로 MQTT_KEEPALIVE는 추가.

C-2. 원격 검증 플레이북 (R-1 ~ R-10)

ID 검증 항목 방법 통과 기준
R-1 브로커 헬스 SSH 터널 후 curl -sf http://127.0.0.1:8222/healthz HTTP 200
R-2 리스너 + TLS 신원 curl -s .../varz | grep -i mqtt; 모델 P는 openssl s_client -connect H:8883 -servername H MQTT 리스너 노출, SAN에 MQTT_BROKER 포함
R-3 노출 면적 단언 외부 망에서 nmap -Pn -p 1883,4222,8222,8080 <공개IP> 전부 closed/filtered
R-4 왕복 pub/sub + 계정 JetStream 임시 잡 등록 → 구독자 기동 → progress 발행 → 수신 → 종결 수신 성공, JetStream not enabled for account 미발생
R-5 retained 종료 이벤트 (MQTT) --event completed 발행 신규 job_subscriber.py 기동 즉시 최종 이벤트 수신
R-6 브로커 신원 단언 grep -c 'broker.hivemq.com' .mam/delegate_job_logs/$JID/events.ndjson 0, 원격 호스트 등장
R-7 freeze 회귀 (H-1/H-4) freeze 경로 publish_event.pyMAM_ENV_FILE 없이 실행 / 그리고 오타 경로로 실행 공개 브로커로 나가지 않음 — 현재는 양쪽 다 실패가 기대값이며 B-17의 근거
R-8 전체 회귀 스위트 .venv/bin/python -m pytest tests/ -q 현재 기준 290 passed
🆕 R-9 관측자 계정 경계 mam_observer로 NATS 구독 → 이벤트 수신 확인. 이어서 home(계정 HOME)으로 동일 구독 mam_observer 수신, home 0건 (= 격리 정상)
🆕 R-10 N-1 retained 경계 확인 잡 종료 NATS 네이티브 구독자를 새로 붙임 0건 수신이 정상. 수신되면 N-1 전제가 틀린 것이므로 §A-4 문구 재작성

R-9/R-10은 챌린지 C1이 제기한 계정 문제와, 그보다 근본적인 retained 경계를 각각 실증합니다. 특히 R-10은 반증 가능한 형태로 설계되어 있습니다.

R-4 구체 절차:

JID=$(.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
        --registry-dir .mam/jobs \
        register --prompt "remote broker connectivity test" --agent-session "herdr:test")

.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
  --registry-dir .mam/jobs --job "$JID" --event progress --detail "remote broker verified" -v

.venv/bin/python .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py \
  --registry-dir .mam/jobs status --job "$JID" --set completed   # 유령 잡 방지

--registry-dir부모 파서 인자이므로 서브커맨드 앞에 옵니다. register에는 --job-id가 없어 ID는 stdout에서 캡처합니다(실측 확인).

지연 측정:

.venv/bin/python - <<'PY'
import sys, time, statistics
sys.path.insert(0, '.agents/skills/multi-agent-mux-delegate-job/scripts')
import mqtt_common as m
cfg = m.broker_config_from_env()
print(f"target: {cfg.host}:{cfg.port} tls={cfg.tls}")
conn, rtt = [], []
for _ in range(5):
    c = m.make_client("latency", cfg)
    t0 = time.perf_counter(); c.connect(cfg.host, cfg.port, 10); c.loop_start()
    conn.append((time.perf_counter() - t0) * 1000)
    t1 = time.perf_counter()
    info = c.publish("mam/latency/probe", b"x", qos=1); info.wait_for_publish(10)
    rtt.append((time.perf_counter() - t1) * 1000)
    c.loop_stop(); c.disconnect()
print(f"connect  p50={statistics.median(conn):.1f}ms max={max(conn):.1f}ms")
print(f"qos1 rtt p50={statistics.median(rtt):.1f}ms max={max(rtt):.1f}ms")
PY

판정: QoS1 RTT p50 > 200ms면 mqtt.ack_wait 상향, > 1s면 오버레이 경로(릴레이 폴백) 점검. with_retry 백오프가 0.5s→1s→2s이므로 수백 ms RTT에서는 재시도 없이 통과해야 정상입니다.

C-3. 전환(Cutover) 절차 — H-2 대응

[1 드레인] ──> [2 잔여 스캔] ──> [3 .mam.env 교체] ──> [4 R-1~R-10] ──> [5 레거시 차단]
  1. 드레인: 신규 위임 중단, 진행 중 잡이 모두 terminal 될 때까지 대기.
  2. 잔여 스캔 — 옛 브로커에 핀 고정된 레코드 확인:
    .venv/bin/python - <<'PY'
    import json, glob
    for p in sorted(glob.glob('.mam/jobs/*.json')):
        d = json.load(open(p))
        if d.get('status') not in ('completed', 'error', 'cancelled'):
            b = d.get('broker') or {}
            print(f"{d.get('job_id')}  status={d.get('status'):<9} broker={b.get('host')}:{b.get('port')}")
    PY
    
    출력이 비어야 3단계 진입. 남으면 종결 처리하거나 broker 블록을 마이그레이션.
  3. .mam.env 교체 — 코드 변경 0줄.
  4. R-1 ~ R-10 전건 통과.
  5. 레거시 차단: 공개 브로커 주소가 활성 기본값으로 남지 않도록 .mam.env / deploy/install.sh 점검.

5. §D — implementation_plan.md 개정안

D-a. M2 분할

마일스톤 이름 DoD 게이트
M0 문서 정합성 (완료) (통과, 290 실측)
M1 Track 0 내결함성 (완료 — c6b6c77) (통과)
M2a 로컬 스파이크 (Track 1) 격리 클론에서 S-1 ~ S-9 완수 S-3 retained 통과
M2b 원격 프로덕션 전환 (Track 1R) D-1~D-5 교정 + §A/§B 배포 + §C-3 전환 R-3 · R-5 · R-6 · R-9 동시 통과, R-7·R-10 결과 기록
M3 Track 2 보안 A-2 지문 토픽, G-11 지문 토픽 확인 후 레거시 구독 제거 + 관측자 권한/Export subject 동시 갱신
M4 Track 3 동기화 문서/배포 정합 전체 스위트 Green

M2b 진입 선행 조건: D-1 ~ D-5 교정이 PRIVATE_SERVER.md에 반영되고 신규 가드가 통과해야 합니다. 교정 전 배포는 D-1(스토리지 유실)·D-2(healthcheck 불가)·D-3(모니터링 공개)·D-4(TLS 접속 불가)로 반드시 실패합니다.

🆕 M3에 추가된 결합 항목: 토픽 루트가 python/mqtt/jobs/…mam/<fp>/jobs/…로 바뀌면 §A-1의 mam_observer.subscribe.allow와 (Option A 채택 시) export subject가 조용히 매칭 실패합니다. 가드 G-D9가 이를 기계적으로 강제합니다.

D-b. Track 1R (신설)

트랙 대상 목표 변경 지점
Track 1R 원격 배포 VPS/홈랩 nats-server 상시 가동 및 MAM 전환 PRIVATE_SERVER.md §9/§5.2, 서버측 nats.conf·docker-compose.yml, .mam.env

D-c. M2b 단계별 로드맵

[P0 교정]      D-1~D-5 문서 교정 + §5.2 경계 명문화 + 신규 가드 7종
   ▼
[P1 서버 준비] VPS/홈랩 프로비저닝, Docker, Tailscale 가입
   ▼
[P2 배포]      nats.conf + compose 기동, healthcheck healthy 확인
   ▼
[P3 잠금]      바인드 주소 한정 + UFW + ss/nmap 로 노출 면적 0 단언 (R-3)
   ▼
[P4 전환]      드레인 → 잔여 스캔 → .mam.env 교체 (§C-3)
   ▼
[P5 검증]      R-1 ~ R-10. R-5(retained) / R-9(계정 경계) 를 최종 관문으로
   ▼
[P6 상시화]    로그 로테이션, JetStream 볼륨 백업, healthcheck 알림

P6 최소 요건: nats-data 볼륨 주기 스냅샷(retained 종료 이벤트가 여기 있음 — 볼륨 유실 = 이벤트 유실), healthcheck 상태 감시, JetStream 리플레이 스트림 사용 시 max_age/max_bytes 상한 필수.

D-d. 체크리스트

### M2b: 원격 프로덕션 전환 (Track 1R)
- [ ] D-1 store_dir 절대경로 교정 + 비인용 heredoc (PRIVATE_SERVER.md:73, :146)
- [ ] D-2 이미지 핀 nats:2.12-alpine + /healthz healthcheck
- [ ] D-3 8222/8080 바인드 주소 한정
- [ ] D-4 TLS 절의 IP 예시 제거 및 DNS/SAN 요건 명시
- [ ] D-5 .mam.env.example 정합 (MQTT_KEEPALIVE 추가 / RETRY·MAX_RETRIES 강등)
- [ ] N-1 §5.2 에 retained=MQTT 전용 경계 명문화 + §A-1 mam_observer 추가
- [ ] 신규 가드 G-D5(강화) ~ G-D9, G-R1, G-R2 구현 및 mutation 확인 (290 → 297)
- [ ] 서버 배포 및 R-3 노출 면적 0 단언
- [ ] §C-3 드레인·잔여 스캔 후 .mam.env 전환
- [ ] R-1 ~ R-10 전건 통과 (R-5 / R-9 최종 관문)

6. 회귀 가드 (7종) — tests/test_deploy_freshness.py::test_d7 계보

ID 가드 내용 변이 검출 기준 (Mutation)
G-D5 🔺강화 2항 검사: (i) 펜스 블록 내 모든 store_dir: 값이 /로 시작하는 절대경로, (ii) 해당 값을 기록하는 heredoc 구분자가 비인용(<<EOF). $HOME은 비인용 heredoc 안에서만 허용 ~/.local/... 복원 시 FAIL 그리고 <<'EOF' + "$HOME/..." 조합 도입 시에도 FAIL
G-D6 문서 내 모든 nats: 이미지 참조가 latest가 아니고 -alpine 포함 nats:latest 복원 시 FAIL
G-D7 compose 예제의 8222 게시 항목이 127.0.0.1: 접두를 가짐 "8222:8222" 복원 시 FAIL
G-D8 MQTT_TLS=1이 등장하는 예제 블록 안의 MQTT_BROKER 값이 IP 리터럴이 아님 MQTT_BROKER="192.168.1.100" + TLS 조합 복원 시 FAIL
🆕 G-D9 문서 config 예제의 subject 리터럴(mam_observer.subscribe.allow, Option A의 export/import subject)이 mqtt_common.DEFAULT_TOPIC_ROOT를 점 표기로 변환한 값과 접두 일치 DEFAULT_TOPIC_ROOTmam/<fp>/jobs로 바꾸고 문서를 갱신하지 않으면 FAIL
G-R1 run_loop.shexport MAM_ENV_FILE= 라인 존재 단언 + .agents만 있고 .mam.env가 없는 임시 루트에서의 회귀 동작을 명시적으로 고정 run_loop.sh:106 export 제거 시 FAIL
G-R2 코드가 읽는 모든 MQTT_* 이름이 .mam.env.example에 존재(특히 MQTT_KEEPALIVE)하고, 코드가 읽지 않는 이름은 활성 기본값으로 기록되지 않음 MQTT_KEEPALIVE 제거 또는 MQTT_RETRY_INTERVAL=2 활성 복원 시 FAIL

구현 규칙(Rev.1 승계 + C3 반영 확인):

  • 문서 가드는 펜스 코드 블록에 한정해 스캔하고, 산문 errata(예: "과거에는 nats:latest를 권장했으나…")가 오탐되지 않음을 동반 단언합니다. — 이 규정은 Rev.1 §6에 이미 있었으며 C3의 요청과 동일합니다. 변경 없이 재확인합니다.
  • G-D9와 G-R2는 소스에서 값을 정적으로 수집해 문서와 대조합니다(브로커 접속 없음).

테스트 수 전망: 290 (현재 실측) → 297 (G-D5 강화 + G-D6~G-D9, G-R1, G-R2) → 298 (M3의 G-11).


7. 롤백 전략

실패 지점 롤백 비용
R-5 retained 실패 .mam.envMQTT_BROKEReclipse-mosquitto로 교체 코드 0줄, 즉시
R-9 계정 경계 실패 mam_observer 권한 블록만 수정, MAM 본체 무영향 서버 설정 1곳
원격 링크 불안정 .mam.env를 직전 값으로 원복 코드 0줄
서버 전소 Track 0 덕분에 루프는 계속 완주(디스크 폴백). 관측만 일시 상실 0
JetStream 볼륨 유실 retained 종료 이벤트 유실 → 백업 스냅샷 복구 P6 백업 필요

Track 0(B-14/B-15)은 브로커 제품·위치와 무관한 순이득이므로 롤백하지 않습니다. 원격 전환 전체가 가역적인 이유가 M1 완료 덕분입니다.


8. 후속 코드 과제 (Rev.2 재정의)

B-17 🔺재정의 — _load_dotenv 단일 후보 해석 (P1)

C2 판정: 챌린저의 우선순위 목록은 방향이 옳으나, 근본 결함은 순서가 아니라 "루트를 하나만 정하고 끝낸다"는 구조입니다(§1.10 H-4). 순서만 바꾸면 MAM_ENV_FILE 오타 경로에서 여전히 공개 브로커로 폴백합니다.

처방 — 순서 있는 후보 목록 + 첫 적중 우선(first-hit-wins):

1) $MAM_ENV_FILE            (파일이 실제로 존재할 때만 채택. 부재 시 return 하지 말고 계속 진행)  ← H-4 교정
2) $MAM_REAL_ROOT           (run_loop.sh:104 가 export)
3) $WORKSPACE_ROOT          (run_loop.sh:105 가 export)
4) walk_up(__file__)        (저장소 직접 실행에서 이미 정상 동작함이 실측됨)
5) walk_up(os.getcwd())     (freeze 경로를 수동 실행하는 경우를 구제 — 챌린저 근거가 실측으로 타당)
→ 각 후보에서 .mam.env / .env 를 찾고, 첫 적중을 채택
→ 전부 실패하면 반드시 경고 로그: "no env file found; falling back to public default broker"

Rev.1 대비 / 챌린저 제안 대비 차이 3가지:

  1. MAM_ENV_FILE 부재 시 조기 탈출 제거(H-4). 챌린저 제안으로는 고쳐지지 않는 경로입니다.
  2. walk_up(__file__)을 cwd보다 에 둡니다. 저장소 직접 실행은 이미 정확히 동작함이 실측되었고, cwd를 앞세우면 다른 프로젝트의 .mam.env를 읽는 교차 오염 위험만 커집니다(MAM은 다중 프로젝트 사용을 전제).
  3. cwd는 walk_up(os.getcwd()) 로 둡니다. 저장소 하위 디렉터리에서 실행해도 동작해야 하기 때문입니다.
  4. 최종 폴백 경고는 순서와 무관한 안전망이므로 필수 요건으로 유지합니다. 어떤 순서든 놓칠 수 있습니다.

B-18 — 잡 레코드 자격증명 평문 보관 (P2)

to_registry_block()에서 password를 마스킹하거나 레코드 대신 실행 시점 env에서만 해석. broker_config_from_job()의 override 우선순위 계약(H-2와 동일 지점)과 함께 재검토.


9. 부록 X — Option A (계정 간 Export / Import): 예외 경로

챌린저가 1순위로 제안한 패턴입니다. 기본 채택하지 않으며, 관측자가 실제로 다른 신뢰 도메인에 속할 때만 사용합니다.

문법 검증 완료 (소스 대조):

accounts {
  MAM: {
    jetstream: enabled
    users: [ { user: mam_agent, password: $MAM_BROKER_PASS } ]
    exports: [ { stream: "python.mqtt.jobs.>", accounts: [HOME] } ]   # accounts 로 수입자 제한 권장
  }
  HOME: {
    users: [ { user: home, password: $HOME_BROKER_PASS } ]
    imports: [ { stream: { account: MAM, subject: "python.mqtt.jobs.>" } } ]
  }
}
  • exports: [ { stream: "..." } ] / imports: [ { stream: { account: X, subject: "..." } } ] 문법은 공식 문서와 일치합니다.
  • > 와일드카드는 유효합니다 — export subject는 IsValidSubject로 검증되며(opts.go:3517) 이 함수는 마지막 토큰의 >를 허용합니다(와일드카드 금지용 IsValidLiteralSubject는 별도 함수이며 export에 쓰이지 않습니다).
  • 수입 측은 prefix: / to:가 없으면 동일 subject로 구독합니다.

기본 채택하지 않는 근거 3가지:

  1. A-2 목적과 상충 — 범용 홈랩 계정에 모든 잡의 페이로드(detail 본문 포함)를 열어 줍니다. 워크스페이스 격리를 강화하려는 Track 2와 반대 방향입니다.
  2. M3 조용한 파손 — export subject가 토픽 루트를 두 번째로 하드코딩하는 지점이 됩니다. 지문 토픽 전환 시 매칭이 조용히 끊깁니다(→ G-D9가 강제).
  3. N-1을 해결하지 못함 — import는 라이브 스트림이며 retained를 옮기지 않습니다. 즉 Option A를 써도 사후 접속 대시보드는 종료 이벤트를 보지 못합니다. Option B와 동일한 한계입니다.

→ 결론: 단일 사용자 홈랩에서는 Option B(§A-1의 mam_observer) 가 저장소 §5.5 처방과 일치하고 노출도 최소입니다. Option A는 "다른 사람/다른 신뢰 도메인이 관측한다"는 요구가 실제로 생겼을 때 도입합니다.


10. 미결 질문 (사용자/Creator 판단 사항)

  1. 노출 모델: 모델 T(Tailscale) 권고. 보유 도메인이 있고 외부 협업자가 붙는다면 모델 P + §B-3.
  2. 관측 클라이언트의 프로토콜: N-1 때문에 사후 상태가 필요하면 MQTT로 붙는 것이 정답입니다. WebSocket/NATS 대시보드를 고집한다면 §5.3 JetStream 리플레이 스트림 옵트인이 필요하며, 이는 추가 디스크 관리 부담을 동반합니다. 어느 쪽을 택할지 결정이 필요합니다.
  3. 서버측 자산의 위치: nats.conf/docker-compose.ymldeploy/nats/로 커밋할지, 서버 로컬에만 둘지. 커밋하면 G-D5~G-D7·G-D9를 실제 파일에 직접 걸 수 있어 문서 가드보다 강해집니다. 시크릿은 어느 쪽이든 .env로 분리.
  4. B-17 우선순위: H-1·H-4는 원격 전환의 보안 목적을 직접 훼손합니다. Planner 권고는 M2b 진입 전 처리입니다.
  5. implementation_plan.md 파일명: 저장소의 대문자 관례와 어긋납니다(이전 리비전에서도 제기).

부록 Y — Rev.2에서 새로 수행한 실측

대상 확인 결과
retained 전달 경로 mqttSendRetainedMsgsToNewSubsmqttPacketSub 처리에서만 호출, sub.mqtt.prm 순회 → MQTT 구독자 전용
NATS 변수 해석 범위 isVariable()비인용 문자열에서만 도달. lexQuotedString"will not interpret any internal contents"
export subject 와일드카드 IsValidSubject가 마지막 토큰 >를 허용(isValidSubjectfwc 분기). export는 IsValidLiteralSubject를 쓰지 않음
export/import 문법 exports: [{stream: "..."}], imports: [{stream: {account: X, subject: "..."}}], prefix/to 지원
계정 격리 "Accounts create isolated tenant subject spaces", jetstream: enabled는 계정 단위
MAM_ENV_FILE 오타 경로 broker.hivemq.com tls=False (다른 후보 미시도)
대조군(정상 경로) nats.private.internal tls=True
freeze 스크립트 + cwd=저장소 broker.hivemq.com tls=False (챌린저의 cwd 도입 근거 성립)
PRIVATE_SERVER.md:230 "MAM과 이를 관측하는 대시보드는 동일한 계정(MAM)에 배치" — Option B는 기존 처방