mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.23
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
[](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml)
|
[](https://github.com/RGJorge/containerflow/actions/workflows/ci.yml)
|
||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
[](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 |
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 8.5 MiB |
+47
-1
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user