ื‘ืกืดื“

๐Ÿ“ท CAMERA STEP 2 โ€” NVR behind a cloudflared tunnel, then CLOSE the eero forwards

docs/CAMERA_STEP2_TUNNEL_RUNBOOK.md ยท last changed (pre-VM history) ยท rendered from GitHub master

๐Ÿ“ท CAMERA STEP 2 โ€” NVR behind a cloudflared tunnel, then CLOSE the eero forwards

Written 2026-07-01, reframed same day per Sam (docs-only session โ€” NOTHING installed or changed yet). LIVING doc.
Where we are: cameras are ALREADY PC-FREE โ€” ops-api nvrFetch() hits the NVR directly (Architecture B, live, cams verified 200+JPEG). Nothing about the camera pipeline needs fixing.
The ONE remaining task is security: nvrFetch currently reaches the NVR (192.168.4.47) through eero WAN port-forwards 8500 + 8501 (verified open 2026-07-01; DDNS d6468120.eero.online โ†’ 74.101.240.91). Raw internet-facing Hikvision is actively exploited. Step 2 = swap that path for a cloudflared tunnel, then close the forwards.
Context: docs/CAMERA_SYSTEM.md ยท skills camera-bridge-runbook + cloudflare-deploy-safe.


1 ยท THE HOST โ€” buy/repurpose a small always-on LAN box (~$50). That's the whole decision.

A cloudflared tunnel terminates where cloudflared runs, so the tunnel host MUST sit on the home LAN with a private route to 192.168.4.47. Everything else is ruled out:
- The NVR itself โ€” Hikvision NVRs can't run cloudflared. โŒ
- The eero โ€” can't run third-party software. โŒ
- The DigitalOcean VM (104.248.227.149 / Tailscale 100.79.23.85) โ€” NOT on the home LAN; no private route to the NVR; can never be the tunnel origin. (A Tailscale subnet router would itself require a home box, so it adds nothing.) โŒ
- A dedicated small always-on device on the LAN โ€” โœ… the answer. Runs only cloudflared; idle load is negligible; survives reboots as a service.

Hardware shortlist (~$50, any of these):
| Box | Notes |
|---|---|
| Used mini-PC (Dell/Lenovo/HP micro, eBay ~$40-60) | x86, most headroom; also unlocks the go2rtc-off-PC durability end state later (CAMERA_SYSTEM.md roadmap) |
| Raspberry Pi 4/5 (or a Pi 3 already in the house) | cloudflared ships official ARM builds; prefer wired Ethernet |
| Any retired laptop/NUC already owned | $0 if one exists; lid closed, power settings = never sleep |

Box setup once: wired Ethernet if possible ยท DHCP reservation in the eero app ยท BIOS/OS set to auto-boot after power loss.


2 ยท TUNNEL SETUP (on the box)

Canonical path โ€” fresh tunnel nvr (Linux/Pi)

# 1. Install cloudflared (.deb/.rpm from pkg.cloudflare.com โ€” ARM + x86 builds)
cloudflared tunnel login                      # browser auth to the hookstreetservices.com zone
cloudflared tunnel create nvr                 # note the tunnel UUID

Config /etc/cloudflared/config.yml:

tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json
ingress:
  - hostname: nvr.hookstreetservices.com
    service: http://192.168.4.47:8500      # NVR LAN IP, HTTP/ISAPI port
  - service: http_status:404
cloudflared tunnel route dns nvr nvr.hookstreetservices.com
sudo cloudflared service install
sudo systemctl enable --now cloudflared
systemctl status cloudflared                  # active (running); survives reboot

(Windows variant โ€” only if the box ends up being a Windows mini-PC: winget install Cloudflare.cloudflared, same login/create, config at C:\Users\<user>\.cloudflared\config.yml, same route dns, then cloudflared service install from an admin prompt.)

Shortcut IF re-homeable โ€” reuse the existing treitel-cameras managed tunnel

Tunnel treitel-cameras (id 16a75a7b-5ee7-4715-8bf5-b6ae746eb8da) is remotely managed โ€” ingress editable via CF API. If its connector credentials can be re-homed onto the new box (connector runs there instead), the NVR is ONE added ingress rule:

{ "hostname": "nvr.hookstreetservices.com", "service": "http://192.168.4.47:8500" }

via PUT /accounts/<acct>/cfd_tunnel/16a75a7b.../configurations (insert BEFORE the catch-all 404; keep the cam.hookstreetservices.com โ†’ http://localhost:1984 rule intact โ€” its localhost only resolves on whatever box runs go2rtc) + proxied CNAME nvr.hookstreetservices.com โ†’ 16a75a7b....cfargotunnel.com. If re-homing is fiddly, don't force it โ€” the fresh tunnel above is the canonical path.

Gate it with CF Access (service token โ€” same pattern as the go2rtc host)

The tunnel hostname is otherwise publicly reachable. Copy the cam.hookstreetservices.com pattern exactly:
1. CF Zero Trust โ†’ Access โ†’ Applications โ†’ new self-hosted app "Treitel NVR" on nvr.hookstreetservices.com.
2. Policies: (a) Sam's Google login (allow), (b) non-identity Service Auth policy for a service token.
3. Service token: reuse ops-api-camera-proxy (the token behind CAM_CF_ID/CAM_CF_SECRET) by adding it to the new app's policy โ€” zero new secrets โ€” or mint a fresh ops-api-nvr-proxy token (then two new Worker secrets, e.g. NVR_CF_ID/NVR_CF_SECRET).
4. โš ๏ธ Code reality check: nvrFetch() (ops-api/src/index.ts ~L480) does NVR digest auth only โ€” it does not send CF-Access-Client-Id/Secret headers today. The Access gate therefore needs a ~4-line addition to nvrFetch (attach the two headers, same as the go2rtc fetch does). Small, but it IS an ops-api code change โ†’ Brain/#042 lane, path-scoped build only.


3 ยท THE ops-api SWITCH (Brain/#042 lane โ€” read cloudflare-deploy-safe FIRST)

Secrets involved (names only, values never in git): NVR_HOST ยท NVR_USER ยท NVR_PASS.
- NVR_USER/NVR_PASS unchanged โ€” digest auth passes straight through the tunnel to the NVR.
- NVR_HOST changes: d6468120.eero.online:8500 โ†’ nvr.hookstreetservices.com (no port; edge terminates TLS).
- โš ๏ธ One-line code change required: index.ts:481 hardcodes const base = `http://${env.NVR_HOST}` โ€” through the tunnel it must be https (make the scheme part of NVR_HOST or default to https). Ship together with the Access-header addition from ยง2.
- Applying it: wrangler secret put NVR_HOST + deploy is the textbook move, but ops-api's 30 secrets are VERSION-EMBEDDED โ€” a bare wrangler secret put/wrangler deploy strands all 30 (broke prod twice 6/24-25). Until the wrangler secret bulk ops-api/.secrets.json durable fix is confirmed done, the safe path is the CF-API secret op on the live version (inherits the other 29) + the path-scoped Workers Build for the code change โ€” exactly per cloudflare-deploy-safe. Verify after with a live /camera/snapshot, never wrangler secret list (it lies โ€” shows 0).


4 ยท CLOSE THE FORWARDS (Sam, eero app) โ€” ONLY after ยง5 passes through the tunnel

  1. eero app โ†’ Settings โ†’ Network settings โ†’ Reservations & port forwards โ†’ 192.168.4.47 ("Hangzhou Hikvision Digital") โ†’ delete/disable the 8500 AND 8501 forwards. (Keep the IP reservation itself.)
  2. Optional hardening: turn OFF Dynamic DNS if nothing else uses d6468120.eero.online.
  3. Rollback (if cameras break and it's urgent): re-add the 8500 forward to 192.168.4.47 in the eero app + set NVR_HOST back to d6468120.eero.online:8500 โ€” restores the current direct path in minutes. Treat as temporary; the hole reopens.

5 ยท VERIFICATION CHECKLIST (not "done" until ALL pass)

# Check Pass
1 curl "https://ops-api.sam-0f0.workers.dev/camera/snapshot?cam=5" -H "referer: https://ops.hookstreetservices.com/" โ†’ 200 + JPEG (ffd8โ€ฆ) with NVR_HOST = the tunnel hostname โฌœ
2 Test 2โ€“3 cams (5/7/8 โ€” never 4, it's permanently dead) โ€” all JPEG โฌœ
3 Direct hit nvr.hookstreetservices.com in a plain browser โ†’ CF Access login wall, not the Hikvision UI โฌœ
4 After closing the forwards: curl -m 10 http://d6468120.eero.online:8500/ from WAN โ†’ timeout/refused (the hole is closed) โฌœ
5 /health shows nvr_host_set: true โฌœ
6 Portal cameras page loads on Sam's phone off Wi-Fi (cellular) โฌœ

Source trail: built from docs/CAMERA_SYSTEM.md (2026-07-01 corrected state) ยท hookstreet-skills/camera-bridge-runbook/SKILL.md ยท hookstreet-skills/cloudflare-deploy-safe/SKILL.md ยท ops-api/src/index.ts L83-86/480-500/1028-1076 (read-only). No infra touched. Reframed 2026-07-01 per Sam: cameras are already PC-free; tunnel host = dedicated always-on LAN box ONLY (the work PC is not an option).

Source trail ยท docs/CAMERA_STEP2_TUNNEL_RUNBOOK.md @ master ยท rendered 2026-07-02 7:23 PM EDT by scripts/build-docs.py ยท the .md in the repo is the truth; this page is the phone-readable view