← 전체로 돌아가기
프로젝트 메모 network -home-son-prj-cam

cam-live-cctv stack setup & recording

DNS-01로 인증서 굽고 go2rtc로 라이브/녹화 돌림

nginxgo2rtccertbotffmpeglinux

cam-live-cctv stack

Live viewing only (no recording in original path, now separate) Stack: browser → nginx → go2rtc → RTSP → cameras

nginx config (LAN-only):

allow 192.168.1.0/24;
allow 192.168.1.1;
allow 100.64.0.0/10;
allow 127.0.0.1;
deny all;
  • 외부 접속 차단, LAN/Tailscale 전용
  • Tailscale은 gateway(.1)가 subnet router로 광고해서 들어옴

DNS & Cert (DNS-01 필수): port 80 막혀있어서 HTTP-01 안됨. 무조건 DNS-01로 인증서 갱신.

certbot command:

certbot certonly --manual --preferred-challenges dns --manual-auth-hook [certbot-auth.sh 경로] --manual-cleanup-hook [certbot-cleanup.sh 경로] -d [subdomain].ericfromkorea.com
  • DNS-01: Namecheap TXT 레코드 조작해서 인증함
  • OpenWrt DNS rebind protection 때문에 LAN에서 도메인 안 먹히면 LuCI에서 host record 직접 박아줘야 함 (cam.ericfromkorea.com → 192.168.1.121)

Recording (rolling buffer): /mnt/4tb/buffer/[cam]/ 에 1분 단위 .ts 파일로 저장 - cam-rec@.service: systemd template 사용 - go2rtc relay 말고 ffmpeg로 메인 스트림 직접 트랜스코딩해서 녹화해야 오디오 안 깨짐 - cam-cleanup.timer: 30일 지난 거 자동 삭제

Clip API (저장 버튼): POST 127.0.0.1:1985/rec/mark - T+20min 지점에 앞뒤 영상 concat 해서 .mp4로 만듦 - ffmpeg -segment_atclocktime 옵션 써야 함 (주의: -segment_atclock 아님)

Cam list: - cam1: TP-Link Vigi (.254, h264) - cam2: ipTIME (.243, HEVC → live/rec 모두 h264 트랜스코딩) - cam240/241/242: Tapo - cam3: .232 (offline)

wtf go2rtc rtsp module disabled 뜨면 rtsp.listen (127.0.0.1:8554) 설정 확인 필수

여기서 배울 것

  1. port 80 막히면 certbot은 DNS-01로 퉁침
  2. OpenWrt rebind protection은 local dnsmasq host record로 해결
  3. ffmpeg 녹화할 때 go2rtc 경유하면 오디오 지저분해짐. 직접 트랜스코딩 권장
  4. ffmpeg segment 옵션 이름 헷갈리지 말 것
원본 파일 보기 (.claude/projects/-home-son-prj-cam/memory/cam-live-cctv.md)
---
name: cam-live-cctv
description: "cam.ericfromkorea.com live CCTV stack (go2rtc), port-80 blocked so certs need DNS-01"
metadata: 
  node_type: memory
  type: project
  originSessionId: 5692e6b4-e1d5-4402-acf9-6e7b8caf4884
---

`/home/son/prj/cam` was a recording system (cam1/2/3 → /mnt/500) but on 2026-06-29 it was repurposed to **live-only viewing, no recording**. See `live/README.md`.

- Stack: browser →HTTPS/WSS→ nginx (`cam.ericfromkorea.com`) → go2rtc (`127.0.0.1:1984`, systemd `go2rtc.service`) →RTSP→ cameras. Grid UI in `live/www/` (2×3 responsive grid; tap → in-page enlarge → tap again → native fullscreen). **LAN-only**: nginx `allow 192.168.1.0/24; allow 192.168.1.1; allow 100.64.0.0/10; allow 127.0.0.1; deny all;`, **no password** (basic auth removed). LAN clients reach the public domain via NAT hairpin (source stays in-subnet). **Tailscale access**: this server does NOT run tailscale; the gateway 192.168.1.1 is the tailnet subnet router advertising 192.168.1.0/24 (source SNATed to .1, in-subnet; nginx also allows 100.64.0.0/10 for SNAT-off).

**DNS — split-horizon so `cam.ericfromkorea.com` works clean (valid cert) on BOTH LAN and Tailscale:**
- Public A record (Namecheap) = **192.168.1.121** (the private server IP, not the public 125.131.103.44). DNS-01 cert renewal is unaffected by this. Tailscale clients resolve via public DNS → .121 → reach it over the subnet route. Pure-external clients get an unroutable private IP → naturally blocked (matches LAN-only intent).
- **Gateway 192.168.1.1 is OpenWrt** (LuCI/dnsmasq) with **DNS rebind protection ON** — it strips private-IP answers for public domains, which broke LAN domain access. Fix: a **local dnsmasq host record** `cam.ericfromkorea.com → 192.168.1.121` (LuCI: Network→Hostnames, or `uci add dhcp domain`). Local records are served authoritatively, bypassing rebind protection, so LAN resolves .121 regardless of the public record.
- Gotcha: pointing the public A record at a private IP WITHOUT the OpenWrt local record = LAN times out (rebind protection). Always add the router record first.

**Cert auto-renewal (whole box)**: port 80 inbound is blocked, so EVERY *.ericfromkorea.com cert must renew via DNS-01, not webroot/HTTP-01. The reusable generic hook `live/certbot-namecheap.py` (+ `certbot-auth.sh`/`certbot-cleanup.sh`) derives the `_acme-challenge.<sub>` record from `$CERTBOT_DOMAIN` and works for any ericfromkorea.com subdomain. On 2026-06-30 several certs (print, cg, dev, horang.dev — `authenticator=manual` with NO hook → silent renewal failure → expired; learn was webroot) were re-issued with this shared hook; all ericfromkorea.com certs now have `manual_auth_hook` set and auto-renew. Still broken: `print.ericfromkorea.dev` (different TLD `.dev`, the com-only hook can't write its TXT zone). To add a new ericfromkorea.com cert/renewal, always pass `--manual --preferred-challenges dns --manual-auth-hook .../certbot-auth.sh --manual-cleanup-hook .../certbot-cleanup.sh`.
- Server IP stays 192.168.1.121 (eno1 static, netplan `10-lan.yaml`); user declined moving to .249.
- 5 live cams: cam1 TP-Link Vigi .60, cam2 ipTIME .139 (HEVC → h264 transcoded by go2rtc ffmpeg), Tapo .240/.241/.242 (user `son_tapo`). cam3 .232 is offline. Grid uses low-res substreams.

**Why / non-obvious:**
- **Port 80 inbound is blocked from the internet** on this server's public IP (125.131.103.44). So Let's Encrypt **HTTP-01 fails** — must use **DNS-01 via Namecheap** (same as `nas.ericfromkorea.com`). cam's renewal hooks: `live/certbot-auth.sh`/`certbot-cleanup.sh` → `live/certbot-namecheap.py` (polls 8.8.8.8 until TXT propagates; fixed-60s waits fail on fresh subdomains).
- go2rtc `ffmpeg:` transcode sources need the internal RTSP loopback — `rtsp.listen` must stay set (`127.0.0.1:8554`), or cam2 breaks with "exec: rtsp module disabled".
- DNS provisioning helper: `tn-namecheap-dns-provision` skill (`/home/son/prj/log/deploy/namecheap_dns.py`).
- This sandbox SIGTERMs foreground `sleep`; run system/service commands with `dangerouslyDisableSandbox` and verify via systemctl/curl retries instead of sleeping.

**Recording (rolling buffer + button clips, added 2026-07-02)**: separate from live view — `/home/son/prj/cam/rec/`. Continuous 720p H264+AAC recording of all 5 cams to 1-min `.ts` segments in `/mnt/4tb/buffer/<cam>/` (systemd template `cam-rec@<cam>.service`, transcodes MAIN stream directly from camera — go2rtc-relayed recording failed/dropped audio). Retention 30 days + size cap via `cam-cleanup.timer`. Storage `/mnt/4tb` (WD Purple 3.4TB; the 458GB /mnt/500 was too small — 720p×5×30d≈2.4TB). Clip API `cam-clip.service` (stdlib python, 127.0.0.1:1985, nginx `/rec/` in cam vhost): `POST /rec/mark` → schedules a job for all cams, at T+20min+90s concats ±20min segments (`.ts` concat -c copy, no re-encode) → `/mnt/4tb/selected/<cam>_<label>.mp4`. Jobs persisted in `.jobs.json`. Web "저장" button in `live/www/index.html`. ffmpeg gotcha: option is `-segment_atclocktime` (not `-segment_atclock`). Camera roster now: cam1 TP-Link Vigi **.254** (moved from .60, h264), cam2 ipTIME **.243** (moved from .139, still HEVC — live transcoded, recording transcoded to 720p h264), cam240/241/242 Tapo. Old hourly recorders cam1/2/3.service stay disabled.