This commit is contained in:
RGJorge
2026-05-07 23:58:10 +00:00
parent 8c91dcfd5a
commit 9e1dadafe7
3 changed files with 57 additions and 1 deletions
+10
View File
@@ -2,6 +2,10 @@
[![CI](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml/badge.svg)](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml) [![CI](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml/badge.svg)](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
![Version](https://img.shields.io/badge/version-v0.1.0-green)
![Docker Required](https://img.shields.io/badge/Docker-required-blue?logo=docker)
![Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun)
[![Last Commit](https://img.shields.io/github/last-commit/RGJorge/containerflow)](https://github.com/RGJorge/containerflow/commits/main)
Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard. Real-time Docker architecture visualizer. Displays services, connections and metrics from all your Docker Compose projects in an interactive dashboard.
@@ -114,6 +118,12 @@ GitHub Actions ejecuta automaticamente en cada push/PR a `main`:
Ver `.github/workflows/ci.yml`. Ver `.github/workflows/ci.yml`.
## Seguridad
- **HTTPS obligatorio en produccion** — el token de autenticacion viaja en headers HTTP. Sin HTTPS, es texto plano visible en la red. Usa un reverse proxy con TLS (nginx, Caddy, Cloudflare Tunnel) delante de ContainerFlow.
- **Rate limiting** — incluido por defecto: 5 intentos fallidos por minuto por IP. Despues del limite, retorna `429 Too Many Requests`. Aplica tanto a la API REST como a la autenticacion WebSocket.
- **Acceso local por defecto** — sin `AUTH_TOKEN`, el servidor solo escucha en `127.0.0.1`. Con `AUTH_TOKEN`, escucha en `0.0.0.0` para acceso remoto.
## Stack ## Stack
| Componente | Tecnologia | | Componente | Tecnologia |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 8.5 MiB

+47 -1
View File
@@ -77,6 +77,39 @@ const HOST = AUTH_TOKEN ? "0.0.0.0" : "127.0.0.1";
const POLL_INTERVAL_MS = 5000; const POLL_INTERVAL_MS = 5000;
const WS_RECONNECT_MS = 3000; const WS_RECONNECT_MS = 3000;
// ── Rate limiting (in-memory, per IP) ──
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const failedAttempts = new Map<string, { count: number; resetAt: number }>();
function isRateLimited(ip: string): boolean {
const entry = failedAttempts.get(ip);
if (!entry) return false;
if (Date.now() > entry.resetAt) {
failedAttempts.delete(ip);
return false;
}
return entry.count >= RATE_LIMIT_MAX;
}
function recordFailedAttempt(ip: string): void {
const now = Date.now();
const entry = failedAttempts.get(ip);
if (!entry || now > entry.resetAt) {
failedAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
} else {
entry.count++;
}
}
// Cleanup stale entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of failedAttempts) {
if (now > entry.resetAt) failedAttempts.delete(ip);
}
}, 5 * 60_000);
// ── Auth middleware ── // ── Auth middleware ──
if (AUTH_TOKEN) { if (AUTH_TOKEN) {
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
@@ -84,8 +117,14 @@ if (AUTH_TOKEN) {
if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".webp") || c.req.path.endsWith(".ico")) return next(); if (c.req.path === "/" || c.req.path.startsWith("/assets") || c.req.path.endsWith(".png") || c.req.path.endsWith(".webp") || c.req.path.endsWith(".ico")) return next();
if (c.req.path === "/api/auth") return next(); if (c.req.path === "/api/auth") return next();
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (isRateLimited(ip)) return c.json({ error: "Too many failed attempts. Try again later." }, 429);
const token = c.req.header("Authorization")?.replace("Bearer ", ""); const token = c.req.header("Authorization")?.replace("Bearer ", "");
if (token !== AUTH_TOKEN) return c.json({ error: "Unauthorized" }, 401); if (token !== AUTH_TOKEN) {
recordFailedAttempt(ip);
return c.json({ error: "Unauthorized" }, 401);
}
return next(); return next();
}); });
} }
@@ -529,6 +568,12 @@ const server = Bun.serve({
// Handle authentication via first message // Handle authentication via first message
if (msg.type === "auth") { if (msg.type === "auth") {
const wsIp = (ws as any).remoteAddress || "unknown";
if (AUTH_TOKEN && isRateLimited(wsIp)) {
native.send(JSON.stringify({ type: "auth_error", reason: "rate_limited" }));
native.close();
return;
}
if (msg.token === AUTH_TOKEN) { if (msg.token === AUTH_TOKEN) {
authenticatedClients.add(native); authenticatedClients.add(native);
native.send(JSON.stringify({ type: "auth_ok" })); native.send(JSON.stringify({ type: "auth_ok" }));
@@ -543,6 +588,7 @@ const server = Bun.serve({
} catch {} } catch {}
}).catch(() => {}); }).catch(() => {});
} else { } else {
if (AUTH_TOKEN) recordFailedAttempt(wsIp);
native.send(JSON.stringify({ type: "auth_error" })); native.send(JSON.stringify({ type: "auth_error" }));
native.close(); native.close();
} }