- TypeScript 79.8%
- Shell 14.7%
- Python 4%
- Dockerfile 1.5%
Today's Caseta5 outage was caused by /etc/tucomunidad/secbox.env
being absent on a legacy install (pre-claim-loop pairing). When
compose.snapshot pushed the new YAML and ran 'podman-compose
--env-file /etc/tucomunidad/secbox.env up', podman-compose
silently no-op'd the file load + every ${VAR:-default} substitution
fell through to literal strings in container env. The tunnel then
tried to refresh-jwt against the literal "${CLOUD_BASE_URL:-...}"
URL, failed parse, retried forever. Cloud lost the WSS; bridge SSH
gone too.
Two prevention layers:
1. ENSURE: before compose-up, the handler creates secbox.env from
/etc/tucomunidad/mgmt-agent.env if absent or empty.
CLOUD_BASE_URL + DEVICE_ID + BOOTSTRAP copied as-is;
DEVICE_GATEWAY_URL derived from MGMT_GATEWAY_URL by swapping
the path suffix. chown to the podman runtime user (tc/tcops)
so podman-compose can read it.
2. SANITY: hard-check secbox.env contains non-empty
DEVICE_ID + CLOUD_BASE_URL. If not, abort with
phase='secbox-env-missing-keys' BEFORE recreating containers.
Better to fail loudly at the cloud than to wedge the box for
hours with broken substitutions.
Verified on Caseta5: '[compose-snapshot] secbox.env sanity ok
(2/2 required keys present)' in journal, HTTP 200 from
compose.snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .forgejo/workflows | ||
| .husky | ||
| docs | ||
| face-service | ||
| mgmt-agent | ||
| prisma | ||
| recovery-agent | ||
| relay-client | ||
| scripts | ||
| src | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| frigate-config-sample.yml | ||
| install.sh | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
tucomunidad SecBox
The edge security service that runs in caseta de seguridad. Connects the caseta's cameras + Frigate NVR to tucomunidad cloud, holds a local replica of bans + scheduled guests, keeps working when the internet is down.
This repo is intentionally isolated from the tucomunidad cloud monorepo. A SecBox lives in physically-accessible hardware; a compromise must not leak server-side source.
What's shipped
- Cloud tunnel (
src/tunnel/) — outbound WSS todevice-gateway.tucomunidad.aiwith auto-reconnect + backpressure. - Local replica (
prisma/schema.prisma) — Postgres subset of cloud schema (Community, BannedVisitor, GuestAuthorization, OutboundEvent). - Sync down (
src/sync/down.ts) — appliessnapshot.*anddelta.*from the cloud to the local DB. - Sync up (
src/sync/up.ts) — durable OutboundEvent queue with drain-on-reconnect; outage-tolerant, idempotent (clientId). - Pairing UI (
src/pairing/) — port 8080 first-boot HTTP form; pastes the QR JSON, validates, persists env, restarts secbox. - LAN HTTPS (
src/lan/) — port 8443 self-signed; placeholder PWA fallback endpoints (/api/lan/ban-list,/api/lan/schedule). - Frigate proxy (
src/handlers/frigate.ts) — single-frame snapshots + continuous MJPEG stream over the tunnel. - Health metrics (
src/health/) — periodichealth.reportto cloud (containers, disk, memory, CPU, queue depth). - CI (
.forgejo/workflows/build.yml) — multi-arch image publish on push to main.
Architecture
The cloud spec, threat model, and protocol live in the cloud's plan file. In short, the SecBox:
- Dials the cloud over WSS — outbound only, no inbound ports.
- Authenticates with a device-JWT rotated nightly.
- Holds a local Postgres replica (Community, Unit, User, GuestAuthorization, BannedVisitor — all single-community).
- Runs Frigate (NVR + camera ingest) and proxies to it.
- Hosts the guard PWA on caseta WiFi for offline operation.
- Pushes events (visitor entries, motion clips, face matches, health metrics) up the tunnel.
Install
On a Debian 12 host (≥ 16 GB RAM, ≥ 1 TB SSD):
curl -fsSL https://tucomunidad.ai/install/secbox | sh
(For the dev environment use https://dev.tucomunidad.ai/install/secbox.)
The installer pulls images, sets up systemd user services, and
exposes a local pairing UI on port 8080. Open
http://<box-LAN-IP>:8080 and paste the QR JSON from the
super-admin's /admin/devices page to claim the SecBox.
Develop
git clone https://git.intino.ai/tucomunidad.ai/secbox.git
cd secbox
npm install # installs husky pre-commit hook automatically
npx prisma generate
# Copy .env.example to .env and fill in cloud + frigate URLs.
npm run dev
Pre-commit checks
npm install registers a husky pre-commit hook that runs
tsc --noEmit (the same strict-mode TypeScript check CI runs).
Local commits fail fast on type errors instead of round-tripping
through CI. To run the check manually:
npm run lint:ts # type check only
npm run check # type check + eslint
Bypass the hook only for emergencies: git commit --no-verify.
Media relay (provider-swappable)
Recordings playback (HLS) and snapshot strips reach the browser via a media relay — a tunnel that runs alongside the secbox process and exposes the box's local HTTPS server (port 8443) at a public URL the browser hits directly. Our cloud handles signaling + auth; bytes never flow through it.
The relay-client container bakes both cloudflared and rathole so
switching providers is a config change in /etc/tucomunidad/secbox.env,
not a rebuild or reinstall:
# Phase 1 (PoC, free): Cloudflare Tunnel
TUNNEL_PROVIDER=cloudflare
TUNNEL_TOKEN=eyJhI...
# Phase 2 (production, self-hosted): rathole on a Hetzner VPS
TUNNEL_PROVIDER=rathole
TUNNEL_SERVER=relay.humket.com:6000
TUNNEL_TOKEN=<per-device-secret>
TUNNEL_REMOTE_NAME=<device-id>
# Disabled — no media tunnel, recordings fall back to cloud-relay
TUNNEL_PROVIDER=none
After editing the env file, podman-compose restart relay-client
picks up the new provider. No code change, no compose change, no
secbox-process restart needed. Architecture rationale + migration
triggers in the cloud repo's docs/MEDIA-RELAY-PLAN.md.
Wire format
Documented in docs/wire-format.md. The cloud's mirror is at
src/lib/services/device-gateway/messages.ts in the tucomunidad
repo. Both sides version messages by their type discriminator;
older boxes silently ignore unknown types. CI runs a contract test
against a frozen golden message set to catch drift.
Updating a deployed box
Two install modes, two update paths:
Container-mode (Caseta5-style podman-compose):
- Push commits to
main. CI (.forgejo/workflows/build.yml) auto- builds + publishesgit.intino.ai/tucomunidad-images/secbox:stableto the Forgejo registry on every push. - Watchtower on each box polls the registry every
WATCHTOWER_POLL_INTERVAL(24h default) and pulls the new tag. Container is restarted automatically. - To force-pull immediately:
podman-compose pull secbox && podman-compose up -d secbox - Verify on the box:
podman logs --tail 30 secboxshould show the new boot banner with whatever's freshest indist/index.js.
Host-mode (Baldwin-style raw node dist/index.js):
- Build locally (or on this Debian sandbox per the build-where-it's-
fastest convention):
cd /home/c/claude/secbox && npm run build. - tar+scp the artifacts:
The wrapper (tar cf /tmp/secbox-update.tar dist src scp /tmp/secbox-update.tar tc@<box>:/tmp/ ssh tc@<box> 'cd ~/secbox.new && rm -rf dist src && \ tar xf /tmp/secbox-update.tar && \ kill -9 $(pgrep -f "node dist/index.js")'run.sh, started at boot via cron@reboot) auto- respawns within 5s. - Verify:
tail /home/tc/secbox-data/secbox.logfor the boot banner.
The Bearer-aware refresh-jwt path (cloud hardening backlog #1.1)
requires the box to run code from 2026-05-07 or later. Until every
box runs that, keep PORTAL_BOOTSTRAP_ENFORCE_CONSUMED unset on the
cloud — device.bootstrap_reused audit entries are the canary that
tells you which boxes are still on legacy code.
License
Private. All rights reserved by tucomunidad.ai.