Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bundles/frigate/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
# acceptable only for tailnet/loopback deployments.
FRIGATE_URL=http://localhost:8971

# The address YOUR BROWSER uses to open Frigate's own web UI (the panel's Web UI tab
# links to it in a new tab). FRIGATE_URL above is a loopback address only the server
# can reach, so a phone cannot use it. Set this to a Tailscale Serve HTTPS URL, e.g.
# https://frigate.example.ts.net — leave it blank to hide the Web UI link.
# FRIGATE_UI_URL=

# Admin credentials for JWT login (POST /api/login → Bearer token).
# Omit if you have set `auth.enabled: false` in config.yml (local-only / proxy setups).
# FRIGATE_USER=admin
Expand Down
2 changes: 2 additions & 0 deletions bundles/frigate/config.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ cameras:
- detect
- record
detect:
# Frigate 0.18 needs this, or detection stays off for the camera.
enabled: true
width: 1280
height: 720
fps: 5
Expand Down
3 changes: 2 additions & 1 deletion bundles/frigate/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "frigate",
"name": "Frigate NVR",
"version": "1.1.1",
"version": "1.2.0",
"description": "Self-hosted NVR with AI object detection — cameras, events, clips. RTSP/ONVIF cameras, local CPU detection, optional Coral TPU.",
"type": "bundle",
"author": "Crow",
Expand All @@ -19,6 +19,7 @@
"requires": { "env": [], "min_ram_mb": 2048, "recommended_ram_mb": 4096, "min_disk_mb": 10000, "recommended_disk_mb": 50000 },
"env_vars": [
{ "name": "FRIGATE_URL", "description": "Frigate base URL (authenticated UI+API on :8971). Use http:// when config.yml has tls.enabled=false (recommended on loopback).", "default": "http://localhost:8971", "required": true },
{ "name": "FRIGATE_UI_URL", "description": "Address your browser uses to open Frigate's own web UI, e.g. a Tailscale Serve HTTPS URL. Leave blank to hide the Web UI link.", "required": false },
{ "name": "FRIGATE_USER", "description": "Admin user for JWT login (POST /api/login). Omit with FRIGATE_PASSWORD if auth.enabled=false in config.yml.", "required": false },
{ "name": "FRIGATE_PASSWORD", "description": "Password for FRIGATE_USER.", "required": false, "secret": true },
{ "name": "FRIGATE_CONFIG_PATH", "description": "Host path for Frigate config directory.", "default": "~/.crow/data/frigate/config", "required": false },
Expand Down
295 changes: 274 additions & 21 deletions bundles/frigate/panel/frigate.js

Large diffs are not rendered by default.

108 changes: 86 additions & 22 deletions bundles/frigate/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@
* TLS: we assume Frigate is configured with `tls.enabled: false` (our
* config.yml.example default). See server/frigate-api.js for the
* re-enable-TLS caveat.
*
* Logins are serialised: Frigate rate-limits `POST /api/login` (one per second by
* default), so a page view that fires several proxied reads at once produced one
* login per read and a pile of 429s reported as bad credentials. One in-flight
* login is shared by every caller, and a 429 is retried after a short pause.
*
* A failed login is remembered for five seconds: with wrong credentials every card,
* event and still on the page would otherwise start its own login and hammer Frigate's
* login endpoint. Inside that window the same error is reported without a request.
*/

import { Router } from "express";
Expand All @@ -19,36 +28,88 @@ const FRIGATE_URL = () => (process.env.FRIGATE_URL || "http://localhost:8971").r
const FRIGATE_USER = () => process.env.FRIGATE_USER || "";
const FRIGATE_PASSWORD = () => process.env.FRIGATE_PASSWORD || "";

const BAD_CREDENTIALS_MESSAGE = "Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD";
const RATE_LIMIT_MESSAGE = "Frigate is rate-limiting logins; try again in a moment";
/** Frigate allows one login per second; wait past that window before trying again. */
const LOGIN_RETRY_MS = 1100;
const LOGIN_MAX_RETRIES = 3;
/** How long a failed login is replayed instead of asking Frigate again. */
const LOGIN_FAILURE_HOLD_MS = 5000;

let authToken = null;
/** The login every waiting caller shares, cleared as soon as it settles. */
let loginInFlight = null;
/** { error, until }: the last failed login, replayed within LOGIN_FAILURE_HOLD_MS. */
let loginFailure = null;

async function getToken() {
if (authToken) return authToken;
if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/** POST /api/login once, retrying a 429 a few times before giving up. */
async function login() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${FRIGATE_URL()}/api/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }),
});
if (!res.ok) throw new Error("Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD");
const data = await res.json().catch(() => ({}));
authToken = data.access_token || data.token || null;
if (!authToken) {
const setCookie = res.headers.get("set-cookie") || "";
const match = setCookie.match(/frigate_token=([^;]+)/);
if (match) authToken = match[1];
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${FRIGATE_URL()}/api/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }),
});
if (res.status === 429) {
if (attempt >= LOGIN_MAX_RETRIES) throw new Error(RATE_LIMIT_MESSAGE);
await sleep(LOGIN_RETRY_MS);
continue;
}
if (res.status === 401 || res.status === 403) throw new Error(BAD_CREDENTIALS_MESSAGE);
if (!res.ok) throw new Error(`Frigate login failed: HTTP ${res.status}`);

const data = await res.json().catch(() => ({}));
let token = data.access_token || data.token || null;
if (!token) {
const setCookie = res.headers.get("set-cookie") || "";
const match = setCookie.match(/frigate_token=([^;]+)/);
if (match) token = match[1];
}
if (!token) throw new Error("Frigate /api/login returned no token");
return token;
}
if (!authToken) throw new Error("Frigate /api/login returned no token");
return authToken;
} finally {
clearTimeout(timeout);
}
}

async function getToken() {
if (authToken) return authToken;
if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null;

// A login that just failed is replayed, not repeated: wrong credentials must cost one
// request, not one per card on the page.
if (loginFailure) {
if (loginFailure.until > Date.now()) throw loginFailure.error;
loginFailure = null;
}

if (!loginInFlight) {
loginInFlight = login()
.then((token) => {
authToken = token;
loginFailure = null;
return token;
})
.catch((err) => {
loginFailure = { error: err, until: Date.now() + LOGIN_FAILURE_HOLD_MS };
throw err;
})
.finally(() => {
loginInFlight = null;
});
}
return loginInFlight;
}

async function frFetch(path, opts = {}) {
const token = await getToken();
const controller = new AbortController();
Expand All @@ -69,18 +130,21 @@ async function frFetch(path, opts = {}) {
}

/**
* Stream bytes from Frigate to the client (for snapshots + clips).
* Stream bytes from Frigate to the client (for stills, snapshots + clips).
* Preserves Content-Type and Content-Length when present.
*
* Cache-Control is the caller's call: a still is replaced every couple of seconds, so it
* must never be stored; an event thumbnail or snapshot is immutable and may be.
*/
async function streamFromFrigate(req, res, path) {
async function streamFromFrigate(req, res, path, cacheControl = "private, max-age=300") {
try {
const upstream = await frFetch(path);
res.status(upstream.status);
const ct = upstream.headers.get("content-type");
if (ct) res.set("content-type", ct);
const cl = upstream.headers.get("content-length");
if (cl) res.set("content-length", cl);
res.set("cache-control", "private, max-age=300");
res.set("cache-control", cacheControl);
const buf = Buffer.from(await upstream.arrayBuffer());
res.end(buf);
} catch (err) {
Expand Down Expand Up @@ -157,7 +221,7 @@ export default function frigateRouter(authMiddleware) {
streamFromFrigate(req, res, `/api/events/${encodeURIComponent(req.params.id)}/snapshot.jpg`),
);
router.get("/api/frigate/:camera/latest.jpg", authMiddleware, (req, res) =>
streamFromFrigate(req, res, `/api/${encodeURIComponent(req.params.camera)}/latest.jpg`),
streamFromFrigate(req, res, `/api/${encodeURIComponent(req.params.camera)}/latest.jpg`, "no-store"),
);

return router;
Expand Down
107 changes: 83 additions & 24 deletions bundles/frigate/server/frigate-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,46 +14,72 @@
*
* Also provides a 30s response cache on GET requests so chatty AI chat polling
* (e.g. list_events every question) can't drive Frigate to 100% CPU.
*
* Logins are serialised: Frigate rate-limits `POST /api/login` (one per second by
* default), so several concurrent tools starting cold produced one login each and a
* pile of 429s reported as bad credentials. One in-flight login is shared by every
* caller, and a 429 is retried after a short pause.
*
* A failed login is remembered for five seconds: a wrong password must cost one request,
* not one per tool call. Inside that window the same error is replayed.
*/

const FRIGATE_URL = () => (process.env.FRIGATE_URL || "http://localhost:8971").replace(/\/+$/, "");
const FRIGATE_USER = () => process.env.FRIGATE_USER || "";
const FRIGATE_PASSWORD = () => process.env.FRIGATE_PASSWORD || "";

const CACHE_TTL_MS = 30_000;
const BAD_CREDENTIALS_MESSAGE = "Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD";
const RATE_LIMIT_MESSAGE = "Frigate is rate-limiting logins; try again in a moment";
/** Frigate allows one login per second; wait past that window before trying again. */
const LOGIN_RETRY_MS = 1100;
const LOGIN_MAX_RETRIES = 3;
/** How long a failed login is replayed instead of asking Frigate again. */
const LOGIN_FAILURE_HOLD_MS = 5000;

let authToken = null;
/** The login every waiting caller shares, cleared as soon as it settles. */
let loginInFlight = null;
/** { error, until }: the last failed login, replayed within LOGIN_FAILURE_HOLD_MS. */
let loginFailure = null;
const cache = new Map(); // key → { data, expires }

/**
* Authenticate with Frigate and obtain a JWT. Cached in-memory.
* Returns null if no credentials are configured (auth.enabled=false mode).
*/
async function getToken() {
if (authToken) return authToken;
if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/** POST /api/login once, retrying a 429 a few times before giving up. */
async function login() {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${FRIGATE_URL()}/api/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }),
});
if (!res.ok) throw new Error("Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD");
// Frigate returns JWT in JSON body AND as cookie; read JSON for the Bearer path.
const data = await res.json().catch(() => ({}));
authToken = data.access_token || data.token || null;
// Some builds only set cookie — fall back to cookie-only mode by stashing a sentinel.
if (!authToken) {
const setCookie = res.headers.get("set-cookie") || "";
const match = setCookie.match(/frigate_token=([^;]+)/);
if (match) authToken = match[1];
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${FRIGATE_URL()}/api/login`, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }),
});
if (res.status === 429) {
if (attempt >= LOGIN_MAX_RETRIES) throw new Error(RATE_LIMIT_MESSAGE);
await sleep(LOGIN_RETRY_MS);
continue;
}
if (res.status === 401 || res.status === 403) throw new Error(BAD_CREDENTIALS_MESSAGE);
if (!res.ok) throw new Error(`Frigate login failed: HTTP ${res.status}`);

// Frigate returns JWT in JSON body AND as cookie; read JSON for the Bearer path.
const data = await res.json().catch(() => ({}));
let token = data.access_token || data.token || null;
// Some builds only set cookie — fall back to cookie-only mode by stashing a sentinel.
if (!token) {
const setCookie = res.headers.get("set-cookie") || "";
const match = setCookie.match(/frigate_token=([^;]+)/);
if (match) token = match[1];
}
if (!token) throw new Error("Frigate /api/login returned no token");
return token;
}
if (!authToken) throw new Error("Frigate /api/login returned no token");
return authToken;
} catch (err) {
if (err.name === "AbortError") throw new Error("Frigate login timed out");
if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED")) {
Expand All @@ -65,6 +91,39 @@ async function getToken() {
}
}

/**
* Authenticate with Frigate and obtain a JWT. Cached in-memory; concurrent callers
* share one in-flight login instead of racing into Frigate's login rate limit, and a
* login that just failed is replayed for five seconds rather than repeated.
* Returns null if no credentials are configured (auth.enabled=false mode).
*/
async function getToken() {
if (authToken) return authToken;
if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null;

if (loginFailure) {
if (loginFailure.until > Date.now()) throw loginFailure.error;
loginFailure = null;
}

if (!loginInFlight) {
loginInFlight = login()
.then((token) => {
authToken = token;
loginFailure = null;
return token;
})
.catch((err) => {
loginFailure = { error: err, until: Date.now() + LOGIN_FAILURE_HOLD_MS };
throw err;
})
.finally(() => {
loginInFlight = null;
});
}
return loginInFlight;
}

/**
* Perform an authenticated request against the Frigate API.
* GETs are cached for 30s unless `opts.nocache` is set.
Expand Down
1 change: 1 addition & 0 deletions docs/developers/port-allocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ These predate this registry and need follow-up resolution outside the MVP scope:
| 8098 | 127.0.0.1 | searxng | MVP PR 5 |
| 8456 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:3070 (Workspace; never Funnel) | W1 2026-10 |
| 8457 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:3071 (Workspace editor; never Funnel). Plus one Serve PATH on the same port, no new port: `/crow-live` → `http://127.0.0.1:<gateway port>/api/workspace/live` (W2 K5 live-edit plugin; set up by the operator from Office › For the admin) | W1 2026-10, W2 path |
| 8458 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:8971 (Frigate's own web UI, opened by the Frigate panel's Web UI link via FRIGATE_UI_URL; never Funnel) | 2026-11 |
| 8010 | 100.64.20.1 (tailscale) | llamacpp-vulkan-qwen36-27b-copilot — co-resident critic refute/probe model, 65536 ctx, text-only (crow-addons) | existing (2026-07-06) |
| 8600 | 127.0.0.1 | funkwhale (nginx, `FUNKWHALE_NGINX_BIND_PORT`; bind address `FUNKWHALE_NGINX_BIND_ADDR`) | existing |
| 8530 | 127.0.0.1 | adguard-home (DNS-over-TLS) | MVP PR 3 |
Expand Down
12 changes: 9 additions & 3 deletions docs/es/integrations/frigate.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Sistema de cámaras autoalojado con detección de objetos por IA. Frigate proces
- Detección local de objetos por IA (persona, auto, perro, paquete, etc.)
- Grabación activada por movimiento con retención configurable
- API REST autenticada con JWT en el puerto :8971
- Panel "Cameras" en el Crow's Nest: lista de cámaras, eventos recientes con miniaturas, estadísticas del sistema
- Panel "Cameras" en el Crow's Nest: tarjetas de cámara con una imagen que se refresca cada dos segundos, eventos recientes con miniaturas, estadísticas del sistema
- Herramientas MCP para cámaras, eventos, snapshots, clips y activación/desactivación de la detección
- La interfaz web propia de Frigate integrada como pestaña en el panel del Nest
- Un enlace a la interfaz web propia de Frigate, en una pestaña nueva (configura `FRIGATE_UI_URL` con la dirección que usa tu navegador)

## Cuándo usarlo vs MotionEye

Expand Down Expand Up @@ -58,6 +58,11 @@ Configura esto en `~/.crow/bundles/frigate/.env`:
FRIGATE_URL=http://localhost:8971
FRIGATE_USER=admin
FRIGATE_PASSWORD=<contraseña de los logs>
# La dirección que usa TU navegador para la interfaz web propia de Frigate (la pestaña
# Web UI del panel enlaza ahí, en una pestaña nueva). FRIGATE_URL es una dirección de
# loopback que solo alcanza el servidor, así que un teléfono no puede usarla.
# Ejemplo: https://frigate.example.ts.net — en blanco oculta el enlace.
# FRIGATE_UI_URL=
```

Luego reinicia el bundle para que el servidor MCP pueda autenticarse:
Expand All @@ -81,6 +86,7 @@ cameras:
- detect
- record
detect:
enabled: true
width: 1280
height: 720
fps: 5
Expand Down Expand Up @@ -170,7 +176,7 @@ El bundle está instalado en `~/.crow/bundles/frigate/` pero faltan `panels.json
y verifica que `~/.crow/panels/frigate.js` exista. Luego reinicia el gateway.

**No se puede acceder a la pestaña de la interfaz web en el panel del Nest**
La cookie JWT de Frigate es de primera parte para `:8971`. El iframe debe cargarse desde el mismo host donde Frigate está publicado. Si el iframe del panel muestra una página en blanco, confirma que `FRIGATE_URL` apunte a una URL accesible desde el navegador.
La interfaz de Frigate corre en `:8971`, vinculado a loopback: solo el servidor de Crow puede abrir esa dirección, y un teléfono que abre `localhost:8971` se alcanza a sí mismo. Configura `FRIGATE_UI_URL` con una dirección que tu navegador sí alcance — una URL HTTPS de Tailscale Serve suele ser la respuesta — y la pestaña Web UI enlazará ahí. Si queda en blanco, la pestaña lo explica en lugar de mostrar un enlace.

## Trabajo futuro

Expand Down
Loading
Loading