From ca7299c646a882880b24176bc2b666ddcaf6fcf2 Mon Sep 17 00:00:00 2001 From: Kralot Date: Sun, 26 Apr 2026 03:02:31 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20autentica=C3=A7=C3=A3o=20opcional,?= =?UTF-8?q?=20VNC=20integrado,=20GPU=20por=20stream,=20proxy=20HLS=20e=20m?= =?UTF-8?q?elhorias=20de=20seguran=C3=A7a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- - Adicionado sistema de autenticação opcional via AUTH_USER/AUTH_PASS: middleware Next.js, página de login, cookie rolling de 30 dias, timingSafeEqual para comparação segura de credenciais; - Adicionado proxy HLS em /api/hls/[...path] que roteia para localhost:8888 internamente; player e player-static atualizados para usar a rota proxy; - Adicionada página /vnc/[id] integrada na UI (iframe + botão Back com auto-hide), substituindo abertura em nova aba; - Adicionado campo gpu: boolean por stream; controlado via {{GPU_FLAGS}} no template do Chromium e no reprovision.mjs; - Ajustado delay da primeira thumbnail para stream.delay + 60 para garantir conclusão do autologin antes da captura; - Atualizado docker-compose.yml: porta 6080 vinculada a localhost, portas 1935 e 8888 comentadas por padrão; - Traduzidos todos os comentários de código do português para o inglês; - Adicionado crédito riguetto.dev no header com underline no hover; - README e CLAUDE.md atualizados com arquitetura, portas e features corretas; --- --- README.md | 64 ++++++++------- build.sh | 2 +- docker/Makefile | 2 +- docker/docker-compose.yml | 14 ++-- scripts/reprovision.mjs | 1 + scripts/stream.template.conf | 3 +- src/app/api/auth/login/route.ts | 40 +++++++++ src/app/api/auth/logout/route.ts | 8 ++ src/app/api/auth/status/route.ts | 6 ++ src/app/api/hls/[...path]/route.ts | 25 ++++++ src/app/api/streams/[id]/[action]/route.ts | 6 +- src/app/api/streams/[id]/route.ts | 2 +- src/app/api/streams/route.ts | 6 +- src/app/login/page.tsx | 95 ++++++++++++++++++++++ src/app/page.tsx | 27 ++++-- src/app/player-static/[id]/route.ts | 5 +- src/app/player/[id]/page.tsx | 8 +- src/app/vnc/[id]/page.tsx | 59 ++++++++++++++ src/components/StreamCard.tsx | 3 +- src/components/StreamForm.tsx | 25 +++++- src/lib/auth.ts | 19 +++++ src/lib/db.ts | 4 +- src/lib/supervisor.ts | 7 +- src/middleware.ts | 38 +++++++++ src/types/stream.ts | 11 ++- 25 files changed, 408 insertions(+), 72 deletions(-) create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/status/route.ts create mode 100644 src/app/api/hls/[...path]/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/vnc/[id]/page.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/middleware.ts diff --git a/README.md b/README.md index 20750e8..6a503fa 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,13 @@ All processes are managed by Supervisord. The web UI is a Next.js app that contr ## Features - **Stream any URL** — if it loads in a browser, it streams -- **Dashboard with live thumbnails** — captured from the HLS output, refreshable on demand -- **VNC access** — inspect any stream's virtual display from the browser via unified noVNC (single port, token routing) +- **Dashboard with live thumbnails** — captured directly from the Xvfb display, refreshable on demand +- **Inline VNC** — inspect any stream's virtual display without leaving the UI (`/vnc/{id}`) - **Autologin with CDP detection** — configure credentials per stream; on restart, queries Chrome DevTools Protocol to skip login if the session is still alive - **Persistent desired state** — streams remember if they were running or stopped and restore automatically on container restart +- **Optional authentication** — set `AUTH_USER` + `AUTH_PASS` to password-protect the entire UI; rolling 30-day session, no login required while active - **Fully configurable encoding** — resolution, scale, FPS, bitrate, preset, tune, GOP, threads, all per stream +- **GPU acceleration** — optional per-stream Chromium GPU flag (disabled by default for container compatibility) - **Built-in HLS player** — watch any stream in the browser; also serves a standalone embeddable HTML page per stream ## Quick Start @@ -39,11 +41,13 @@ services: - seccomp:unconfined # required for Chromium syscalls environment: TZ: America/Sao_Paulo + # AUTH_USER: admin # optional: enables login if both are set + # AUTH_PASS: secure_password ports: - - "3000:3000" # Web UI - - "1935:1935" # RTMP input - - "8888:8888" # HLS output - - "6080:6080" # noVNC + - "3000:3000" # Web UI — main entry point + - "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN + # - "1935:1935" # RTMP — expose only for external ingest (e.g. OBS) + # - "8888:8888" # HLS — internal only; proxied through the UI at /api/hls/ volumes: - decap-stream:/app/data @@ -63,12 +67,12 @@ Open **http://localhost:3000** and add your first stream. ## Ports -| Port | Service | -|------|---------| -| `3000` | Web UI (Next.js) | -| `1935` | RTMP ingest (MediaMTX) | -| `8888` | HLS output (MediaMTX) | -| `6080` | noVNC unified (token-based routing to all streams) | +| Port | Default | Description | +|------|---------|-------------| +| `3000` | exposed | Web UI (Next.js) — sole public entry point | +| `6080` | localhost only | noVNC (token-based routing to all streams) | +| `1935` | commented out | RTMP ingest (MediaMTX) — only needed for external ingest | +| `8888` | commented out | HLS output (MediaMTX) — proxied through Next.js at `/api/hls/` | ## RTMP & HLS URLs @@ -77,8 +81,8 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | Protocol | URL | |----------|-----| | RTMP ingest | `rtmp://:1935/live/` | -| HLS manifest | `http://:8888/live//index.m3u8` | -| VNC | `http://:6080/vnc.html?autoconnect=true&path=websockify%3Ftoken%3D` | +| HLS manifest | `http://:3000/api/hls/live//index.m3u8` | +| VNC (inline) | `http://:3000/vnc/` | ## Stream Configuration @@ -88,7 +92,7 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | `name` | | Display name | | `url` | | URL to open in Chromium | | `user` / `pass` | | Credentials for autologin (optional) | -| `delay` | `15s` | Seconds before ffmpeg starts (allows page to load) | +| `delay` | `15s` | Seconds before ffmpeg starts (allows page to load; also offsets first thumbnail) | | `resolution` | `1920x1080` | Virtual display and capture size | | `scale` | `1280x720` | Output video resolution | | `fps` | `30` | Capture framerate | @@ -98,24 +102,26 @@ Each stream gets a slug ID you define (e.g. `grafana-prod`): | `tune` | `stillimage` | x264 tune (`stillimage` for dashboards, `zerolatency` for dynamic content) | | `gop` | `60` | Keyframe interval (auto-calculated as 2× FPS in the UI) | | `threads` | `0` | ffmpeg encoding threads (`0` = auto-detect) | +| `gpu` | `false` | Enable Chromium GPU acceleration (requires host GPU + container access) | ## Architecture ``` -┌──────────────────────────────────────────────────────────────┐ -│ Container │ -│ │ -│ Next.js :3000 ──API──► Supervisord │ -│ ├── novnc :6080 (global) │ -│ └── per stream: │ -│ ├── xvfb (display) │ -│ ├── chromium (browser) │ -│ ├── autologin (CDP) │ -│ ├── x11vnc (VNC) │ -│ └── ffmpeg (encode) │ -│ │ │ -│ MediaMTX :1935/:8888 ◄────RTMP────────┘ │ -└──────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ Container │ +│ │ +│ Next.js :3000 ──API──► Supervisord │ +│ ├── /api/hls/ ──────► MediaMTX :8888 (internal) │ +│ └── /vnc/{id} ──────► noVNC :6080 (localhost) │ +│ └── per stream: │ +│ ├── xvfb (display) │ +│ ├── chromium (browser) │ +│ ├── autologin (CDP) │ +│ ├── x11vnc (VNC) │ +│ └── ffmpeg (encode) │ +│ │ │ +│ MediaMTX :1935/:8888 ◄────RTMP────────┘ │ +└─────────────────────────────────────────────────────────────┘ ``` - `streams.json` flat file + one directory per stream under `/app/data/streams/{id}/` diff --git a/build.sh b/build.sh index f77891b..1849498 100644 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -DEFAULT_IMAGE="git.kralot.cloud/kralot/decap-stream" +DEFAULT_IMAGE="registry.kralot.cloud/kralot/decap-stream" DEFAULT_VERSION="0.0.0" DEFAULT_LATEST="latest" diff --git a/docker/Makefile b/docker/Makefile index f138e7c..2a753c9 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash -IMAGE ?= git.kralot.cloud/kralot/decap-stream +IMAGE ?= registry.kralot.cloud/kralot/decap-stream TAG ?= "" .PHONY: build push diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 539f4d6..3ddc3d3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,18 +1,20 @@ services: decap-stream: - image: git.kralot.cloud/kralot/decap-stream:latest + image: ghcr.io/riguettodev/decap-stream:latest container_name: decap-stream restart: unless-stopped - shm_size: "2gb" + shm_size: "1gb" security_opt: - seccomp:unconfined environment: TZ: America/Sao_Paulo + # AUTH_USER: admin # Se definido (junto com AUTH_PASS), habilita login + # AUTH_PASS: secure_password ports: - - "3000:3000" # Web UI - - "1935:1935" # RTMP (MediaMTX) - - "8888:8888" # HLS (MediaMTX) - - "6080:6080" # VNC (noVNC) + - "3000:3000" # Web UI — main entry point + - "127.0.0.1:6080:6080" # VNC — localhost only; remote access via tunnel/VPN + # - "1935:1935" # RTMP — internal only; expose only for external ingest (e.g. OBS) + # - "8888:8888" # HLS — internal only; proxied through Next.js at /api/hls/ volumes: - streams:/app/data/streams # Persistent: streams.json, chrome profiles, thumbs # - logs:/app/data/logs # Optional diff --git a/scripts/reprovision.mjs b/scripts/reprovision.mjs index c9f74d8..d3167d1 100644 --- a/scripts/reprovision.mjs +++ b/scripts/reprovision.mjs @@ -48,6 +48,7 @@ for (const stream of streams) { THREADS: stream.threads ?? 0, USER: stream.user ?? '', PASS: stream.pass ?? '', + GPU_FLAGS: stream.gpu ? '' : ' --disable-gpu \\\n', } fs.writeFileSync(path.join(dir, 'stream.conf'), render(confTpl, vars), 'utf-8') diff --git a/scripts/stream.template.conf b/scripts/stream.template.conf index b3db23f..bae9c08 100644 --- a/scripts/stream.template.conf +++ b/scripts/stream.template.conf @@ -17,8 +17,7 @@ command=bash -c "rm -rf \ && chromium \ --no-sandbox \ --test-type \ - --disable-gpu \ - --window-size={{CHROME_SIZE}} \ +{{GPU_FLAGS}} --window-size={{CHROME_SIZE}} \ --start-fullscreen \ --user-data-dir=/app/data/streams/{{STREAM_ID}}/chrome-profile \ --no-first-run \ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..e46de84 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,40 @@ +import crypto from "crypto" +import { type NextRequest, NextResponse } from "next/server" +import { AUTH_ENABLED, COOKIE_NAME, computeSessionToken } from "@/lib/auth" + +function hash(s: string) { + return crypto.createHash("sha256").update(s).digest() +} + +export async function POST(request: NextRequest) { + if (!AUTH_ENABLED) { + return NextResponse.json({ ok: true }) + } + + let user: string, pass: string + try { + const body = await request.json() + user = String(body.user ?? "") + pass = String(body.pass ?? "") + } catch { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }) + } + + try { + const userOk = crypto.timingSafeEqual(hash(user), hash(process.env.AUTH_USER!)) + const passOk = crypto.timingSafeEqual(hash(pass), hash(process.env.AUTH_PASS!)) + if (!userOk || !passOk) throw new Error() + } catch { + return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }) + } + + const token = await computeSessionToken() + const res = NextResponse.json({ ok: true }) + res.cookies.set(COOKIE_NAME, token, { + httpOnly: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 30, // 30 days — auto-renewed on every request (rolling session) + }) + return res +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..460bde6 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" +import { COOKIE_NAME } from "@/lib/auth" + +export async function POST() { + const res = NextResponse.json({ ok: true }) + res.cookies.delete(COOKIE_NAME) + return res +} diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts new file mode 100644 index 0000000..76e1701 --- /dev/null +++ b/src/app/api/auth/status/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server" +import { AUTH_ENABLED } from "@/lib/auth" + +export async function GET() { + return NextResponse.json({ enabled: AUTH_ENABLED }) +} diff --git a/src/app/api/hls/[...path]/route.ts b/src/app/api/hls/[...path]/route.ts new file mode 100644 index 0000000..4d1b9d2 --- /dev/null +++ b/src/app/api/hls/[...path]/route.ts @@ -0,0 +1,25 @@ +import { type NextRequest, NextResponse } from "next/server" + +type Ctx = { params: Promise<{ path: string[] }> } + +export async function GET(req: NextRequest, { params }: Ctx) { + const { path } = await params + const upstream = `http://localhost:8888/${path.join("/")}` + + try { + const res = await fetch(upstream, { + headers: { Accept: req.headers.get("Accept") ?? "*/*" }, + }) + + if (!res.ok) return new NextResponse(null, { status: res.status }) + + const headers = new Headers() + const ct = res.headers.get("content-type") + if (ct) headers.set("content-type", ct) + headers.set("cache-control", "no-cache") + + return new NextResponse(res.body, { status: 200, headers }) + } catch { + return new NextResponse(null, { status: 502 }) + } +} diff --git a/src/app/api/streams/[id]/[action]/route.ts b/src/app/api/streams/[id]/[action]/route.ts index 1a8dce4..7bde888 100644 --- a/src/app/api/streams/[id]/[action]/route.ts +++ b/src/app/api/streams/[id]/[action]/route.ts @@ -11,15 +11,15 @@ export async function POST(_req: Request, { params }: Ctx) { switch (action) { case "start": - saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19 + saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) startStream(id) break case "stop": - saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) // #19 + saveStream({ ...stream, desiredState: "stopped", updatedAt: new Date().toISOString() }) stopStream(id) break case "restart": - saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) // #19 + saveStream({ ...stream, desiredState: "running", updatedAt: new Date().toISOString() }) restartStream(id) break default: diff --git a/src/app/api/streams/[id]/route.ts b/src/app/api/streams/[id]/route.ts index 34fde4a..b62e4c3 100644 --- a/src/app/api/streams/[id]/route.ts +++ b/src/app/api/streams/[id]/route.ts @@ -18,7 +18,7 @@ export async function PATCH(req: Request, { params }: Ctx) { if (!stream) return NextResponse.json({ error: "not found" }, { status: 404 }) const body = (await req.json()) as StreamUpdate - // id e portas não podem ser alterados via PATCH + // id and ports are immutable — strip them from PATCH body const { id: _id, ...safe } = body as StreamUpdate & { id?: string } void _id diff --git a/src/app/api/streams/route.ts b/src/app/api/streams/route.ts index ebf02e4..7579713 100644 --- a/src/app/api/streams/route.ts +++ b/src/app/api/streams/route.ts @@ -29,9 +29,9 @@ export async function POST(req: Request) { const stream = { ...STREAM_DEFAULTS, ...body, - scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), // #13 + scale: normalizeScale(body.scale ?? STREAM_DEFAULTS.scale), ...ports, - desiredState: "running" as const, // #19 + desiredState: "running" as const, order: nextOrder, createdAt: now, updatedAt: now, @@ -40,7 +40,7 @@ export async function POST(req: Request) { saveStream(stream) provisionStream(stream) startStream(stream.id) - captureThumb(stream.id, 60) + captureThumb(stream.id, stream.delay + 60) return NextResponse.json(stream, { status: 201 }) } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..916692f --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,95 @@ +"use client" + +import { Suspense, useState } from "react" +import { useSearchParams } from "next/navigation" +import { useRouter } from "next/navigation" + +function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const from = searchParams.get("from") ?? "/" + + const [user, setUser] = useState("") + const [pass, setPass] = useState("") + const [error, setError] = useState("") + const [loading, setLoading] = useState(false) + + async function submit(e: React.FormEvent) { + e.preventDefault() + setError("") + setLoading(true) + + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user, pass }), + }) + + setLoading(false) + + if (res.ok) { + router.push(from) + router.refresh() + } else { + setError("Invalid credentials") + setPass("") + } + } + + const inputClass = "w-full rounded border border-[#222] bg-[#1a1a1a] px-3 py-2 text-sm text-[#ededed] outline-none focus:ring-1 focus:ring-[#444] transition-colors placeholder:text-[#555]" + + return ( +
+
+ +
+
+ + Decap Stream +
+

Sign in to continue

+
+ +
+ setUser(e.target.value)} + /> + setPass(e.target.value)} + /> + + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} + +export default function LoginPage() { + return ( + }> + + + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 43947ea..1cf597e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ "use client" import { useEffect, useState, useCallback } from "react" -import { Plus, Download, RefreshCw, Settings, X } from "lucide-react" +import { Plus, Download, RefreshCw, Settings, X, LogOut } from "lucide-react" import { StreamCard } from "@/components/StreamCard" import type { Stream } from "@/types/stream" import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" @@ -54,7 +54,7 @@ function SkeletonCard({ size = "sm" }: { size?: CardSize }) { ) } -// #7 — settings popup +// settings popup function SettingsPopup({ cardSize, onCardSize, onClose }: { cardSize: CardSize onCardSize: (s: CardSize) => void @@ -101,6 +101,7 @@ export default function GalleryPage() { const [refreshing, setRefreshing] = useState(false) const [cardSize, setCardSize] = useState("md") // md = Medium = antigo Big const [settingsOpen, setSettingsOpen] = useState(false) + const [authEnabled, setAuthEnabled] = useState(false) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) @@ -144,7 +145,10 @@ export default function GalleryPage() { setStatuses(Object.fromEntries(results)) }, []) - useEffect(() => { fetchStreams() }, [fetchStreams]) + useEffect(() => { + fetchStreams() + fetch("/api/auth/status").then(r => r.json()).then(d => setAuthEnabled(d.enabled)) + }, [fetchStreams]) useEffect(() => { if (streams.length === 0) return @@ -163,7 +167,7 @@ export default function GalleryPage() { const showSkeleton = loading || refreshing - // #6 — todos os botões do header com mesmo padding e tamanho + // all header buttons share the same padding and height const btnBase = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-border hover:bg-[#2a2a2a] active:bg-[#333] transition-colors cursor-pointer" const btnPrimary = "flex items-center gap-1.5 text-sm px-3 py-1.5 h-8 rounded border border-primary bg-primary text-primary-foreground hover:bg-[#2a2a2a] hover:text-foreground hover:border-border active:bg-[#333] transition-colors cursor-pointer" @@ -173,9 +177,11 @@ export default function GalleryPage() {
Decap Stream

Decap Stream

+ · + riguetto.dev
- {/* #6 — refresh com h-8 explícito igual aos outros */} + {/* explicit h-8 to match other header buttons */} @@ -189,10 +195,19 @@ export default function GalleryPage() { + {authEnabled && ( + + )}
- {/* #7 — popup de configurações */} + {/* settings popup */} {settingsOpen && ( } -export async function GET(req: NextRequest, { params }: Ctx) { +export async function GET(_req: NextRequest, { params }: Ctx) { const { id } = await params - const host = req.headers.get("host")?.split(":")[0] ?? "localhost" const html = ` @@ -27,7 +26,7 @@ export async function GET(req: NextRequest, { params }: Ctx) {