From 42723015f3705ef73aa9830f63df8b853f532c0d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 21:43:26 +0000 Subject: [PATCH] Fix security warnings --- src/integrations/supabase/index.ts | 3 + src/pages/Login.tsx | 109 ++++++++-- supabase/config.toml | 11 +- supabase/functions/docker-api/index.ts | 135 +++++++++++++ supabase/functions/docker-logs/index.ts | 116 +++++++++++ supabase/functions/docker-metrics/index.ts | 186 ++++++++++++++++++ ...6_8d0737a8-a881-40a2-9a58-7c0e261013ef.sql | 49 +++++ 7 files changed, 588 insertions(+), 21 deletions(-) create mode 100644 src/integrations/supabase/index.ts create mode 100644 supabase/functions/docker-api/index.ts create mode 100644 supabase/functions/docker-logs/index.ts create mode 100644 supabase/functions/docker-metrics/index.ts create mode 100644 supabase/migrations/20251020214036_8d0737a8-a881-40a2-9a58-7c0e261013ef.sql diff --git a/src/integrations/supabase/index.ts b/src/integrations/supabase/index.ts new file mode 100644 index 0000000..cbf2165 --- /dev/null +++ b/src/integrations/supabase/index.ts @@ -0,0 +1,3 @@ +// This file exports everything from the auto-generated client +export * from './client'; +export * from './types'; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 998779a..24e274c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -2,18 +2,62 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card } from "@/components/ui/card"; -import { Server, Lock, User } from "lucide-react"; +import { Server, Lock, Mail } from "lucide-react"; import { useNavigate } from "react-router-dom"; +import { supabase } from "@/integrations/supabase/client"; +import { useToast } from "@/components/ui/use-toast"; const Login = () => { - const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [isSignup, setIsSignup] = useState(false); + const [loading, setLoading] = useState(false); const navigate = useNavigate(); + const { toast } = useToast(); - const handleLogin = (e: React.FormEvent) => { + const handleAuth = async (e: React.FormEvent) => { e.preventDefault(); - // Mock login - in real app would authenticate - navigate("/dashboard"); + setLoading(true); + + try { + if (isSignup) { + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + username: email.split('@')[0] + } + } + }); + + if (error) throw error; + + toast({ + title: "Account created!", + description: "You can now log in.", + }); + setIsSignup(false); + setPassword(""); + } else { + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) throw error; + + navigate("/dashboard"); + } + } catch (error: any) { + toast({ + title: "Authentication failed", + description: error.message, + variant: "destructive", + }); + } finally { + setLoading(false); + } }; return ( @@ -25,24 +69,26 @@ const Login = () => {
- Lightweight container monitoring and control + {isSignup ? 'Create an account to get started' : 'Lightweight container monitoring and control'}
- -- First time? You'll be prompted to change your password after login. -
++ Secure authentication powered by Lovable Cloud +
+ )} ); diff --git a/supabase/config.toml b/supabase/config.toml index b2f73c6..7dd466b 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,10 @@ -project_id = "lyqqeokmoylqtwtikwzb" \ No newline at end of file +project_id = "lyqqeokmoylqtwtikwzb" + +[functions.docker-api] +verify_jwt = true + +[functions.docker-metrics] +verify_jwt = true + +[functions.docker-logs] +verify_jwt = true \ No newline at end of file diff --git a/supabase/functions/docker-api/index.ts b/supabase/functions/docker-api/index.ts new file mode 100644 index 0000000..b764d91 --- /dev/null +++ b/supabase/functions/docker-api/index.ts @@ -0,0 +1,135 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const DOCKER_SOCKET = '/var/run/docker.sock'; + +// Helper to make Unix socket HTTP requests to Docker API +async function dockerRequest(path: string, method = 'GET', body?: any) { + const conn = await Deno.connect({ transport: "unix", path: DOCKER_SOCKET }); + + const request = [ + `${method} ${path} HTTP/1.1`, + 'Host: localhost', + 'Content-Type: application/json', + body ? `Content-Length: ${new TextEncoder().encode(JSON.stringify(body)).length}` : '', + '', + body ? JSON.stringify(body) : '' + ].join('\r\n'); + + await conn.write(new TextEncoder().encode(request)); + + const decoder = new TextDecoder(); + const buffer = new Uint8Array(65536); + const n = await conn.read(buffer); + conn.close(); + + if (!n) throw new Error('No response from Docker'); + + const response = decoder.decode(buffer.subarray(0, n)); + const [headers, ...bodyParts] = response.split('\r\n\r\n'); + const responseBody = bodyParts.join('\r\n\r\n'); + + // Check for chunked transfer encoding + if (headers.includes('Transfer-Encoding: chunked')) { + const chunks: string[] = []; + const lines = responseBody.split('\r\n'); + for (let i = 0; i < lines.length; i++) { + const chunkSize = parseInt(lines[i], 16); + if (chunkSize === 0) break; + if (i + 1 < lines.length) { + chunks.push(lines[i + 1]); + i++; // Skip the chunk data line + } + } + return JSON.parse(chunks.join('')); + } + + return JSON.parse(responseBody); +} + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + throw new Error('Missing authorization header'); + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: authHeader } } } + ); + + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + throw new Error('Unauthorized'); + } + + const url = new URL(req.url); + const path = url.pathname.replace('/docker-api', ''); + + // List all containers + if (path === '/containers' && req.method === 'GET') { + const containers = await dockerRequest('/containers/json?all=true'); + return new Response(JSON.stringify(containers), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Get container details + if (path.match(/^\/containers\/[^\/]+$/) && req.method === 'GET') { + const containerId = path.split('/')[2]; + const details = await dockerRequest(`/containers/${containerId}/json`); + return new Response(JSON.stringify(details), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + // Container actions: start, stop, restart + if (path.match(/^\/containers\/[^\/]+\/(start|stop|restart)$/) && req.method === 'POST') { + const parts = path.split('/'); + const containerId = parts[2]; + const action = parts[3]; + + const details = await dockerRequest(`/containers/${containerId}/json`); + const containerName = details.Name.replace(/^\//, ''); + + await dockerRequest(`/containers/${containerId}/${action}`, 'POST'); + + // Log to audit log + await supabase.from('audit_logs').insert({ + user_id: user.id, + action: action, + container_id: containerId, + container_name: containerName, + details: { timestamp: new Date().toISOString() } + }); + + return new Response(JSON.stringify({ success: true, action, containerId }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + + } catch (error) { + console.error('Docker API error:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/supabase/functions/docker-logs/index.ts b/supabase/functions/docker-logs/index.ts new file mode 100644 index 0000000..68a3e3e --- /dev/null +++ b/supabase/functions/docker-logs/index.ts @@ -0,0 +1,116 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +const DOCKER_SOCKET = '/var/run/docker.sock'; + +serve(async (req) => { + if (req.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + throw new Error('Missing authorization header'); + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: authHeader } } } + ); + + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + throw new Error('Unauthorized'); + } + + const url = new URL(req.url); + const containerId = url.searchParams.get('container'); + const tail = url.searchParams.get('tail') || '100'; + + if (!containerId) { + throw new Error('Container ID required'); + } + + // Connect to Docker socket + const conn = await Deno.connect({ transport: "unix", path: DOCKER_SOCKET }); + + const request = `GET /containers/${containerId}/logs?stdout=true&stderr=true&tail=${tail}&follow=true HTTP/1.1\r\nHost: localhost\r\n\r\n`; + await conn.write(new TextEncoder().encode(request)); + + // Create a readable stream from the socket + const stream = new ReadableStream({ + async start(controller) { + const decoder = new TextDecoder(); + let buffer = new Uint8Array(8192); + let headerRead = false; + + try { + while (true) { + const n = await conn.read(buffer); + if (n === null) break; + + let data = decoder.decode(buffer.subarray(0, n)); + + // Skip HTTP headers on first read + if (!headerRead) { + const headerEnd = data.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + data = data.substring(headerEnd + 4); + headerRead = true; + } + } + + // Docker logs use a special frame format: + // [8]byte{STREAM_TYPE, 0, 0, 0, SIZE1, SIZE2, SIZE3, SIZE4} + // We'll parse and clean this + const lines = data.split('\n').filter(line => { + // Skip binary headers (first 8 bytes of each frame) + if (line.length > 8) { + return line.substring(8).trim().length > 0; + } + return false; + }); + + for (const line of lines) { + if (line.length > 8) { + const cleaned = line.substring(8).trim(); + if (cleaned) { + controller.enqueue(new TextEncoder().encode(`data: ${cleaned}\n\n`)); + } + } + } + } + } catch (error) { + console.error('Stream error:', error); + } finally { + conn.close(); + controller.close(); + } + } + }); + + return new Response(stream, { + headers: { + ...corsHeaders, + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + + } catch (error) { + console.error('Docker logs error:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + }); + } +}); diff --git a/supabase/functions/docker-metrics/index.ts b/supabase/functions/docker-metrics/index.ts new file mode 100644 index 0000000..a9fa4ba --- /dev/null +++ b/supabase/functions/docker-metrics/index.ts @@ -0,0 +1,186 @@ +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.3'; + +const DOCKER_SOCKET = '/var/run/docker.sock'; +const HOST_PROC = '/host/proc'; + +// Helper to make Unix socket HTTP requests +async function dockerRequest(path: string) { + const conn = await Deno.connect({ transport: "unix", path: DOCKER_SOCKET }); + + const request = `GET ${path} HTTP/1.1\r\nHost: localhost\r\n\r\n`; + await conn.write(new TextEncoder().encode(request)); + + const decoder = new TextDecoder(); + const buffer = new Uint8Array(65536); + const n = await conn.read(buffer); + conn.close(); + + if (!n) throw new Error('No response'); + + const response = decoder.decode(buffer.subarray(0, n)); + const [, body] = response.split('\r\n\r\n'); + + return JSON.parse(body); +} + +// Read host metrics from /proc +async function getHostMetrics() { + try { + // CPU info + const cpuInfo = await Deno.readTextFile(`${HOST_PROC}/stat`); + const cpuLine = cpuInfo.split('\n')[0]; + const cpuValues = cpuLine.split(/\s+/).slice(1).map(Number); + const total = cpuValues.reduce((a, b) => a + b, 0); + const idle = cpuValues[3]; + const cpuUsage = ((total - idle) / total) * 100; + + // Memory info + const memInfo = await Deno.readTextFile(`${HOST_PROC}/meminfo`); + const memLines = memInfo.split('\n'); + const memTotal = parseInt(memLines.find(l => l.startsWith('MemTotal'))?.split(/\s+/)[1] || '0'); + const memFree = parseInt(memLines.find(l => l.startsWith('MemFree'))?.split(/\s+/)[1] || '0'); + const memAvailable = parseInt(memLines.find(l => l.startsWith('MemAvailable'))?.split(/\s+/)[1] || '0'); + + // Uptime + const uptime = await Deno.readTextFile(`${HOST_PROC}/uptime`); + const uptimeSeconds = parseFloat(uptime.split(' ')[0]); + + return { + cpu: { usage: cpuUsage.toFixed(2) }, + memory: { + total: memTotal, + free: memFree, + available: memAvailable, + used: memTotal - memFree, + usage: ((memTotal - memAvailable) / memTotal * 100).toFixed(2) + }, + uptime: uptimeSeconds + }; + } catch (error) { + console.error('Error reading host metrics:', error); + return { + cpu: { usage: 0 }, + memory: { total: 0, free: 0, available: 0, used: 0, usage: 0 }, + uptime: 0 + }; + } +} + +serve(async (req) => { + const upgrade = req.headers.get("upgrade") || ""; + + if (upgrade.toLowerCase() !== "websocket") { + return new Response("Expected WebSocket", { status: 400 }); + } + + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return new Response("Missing authorization", { status: 401 }); + } + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: authHeader } } } + ); + + const { data: { user }, error: userError } = await supabase.auth.getUser(); + if (userError || !user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { socket, response } = Deno.upgradeWebSocket(req); + + let intervalId: number; + + socket.onopen = () => { + console.log("WebSocket metrics connection opened"); + + // Send metrics every 2 seconds + intervalId = setInterval(async () => { + try { + const hostMetrics = await getHostMetrics(); + const containers = await dockerRequest('/containers/json'); + + const containerStats = []; + for (const container of containers) { + try { + const stats = await dockerRequest(`/containers/${container.Id}/stats?stream=false`); + containerStats.push({ + id: container.Id, + name: container.Names[0].replace(/^\//, ''), + cpu: calculateCPUPercent(stats), + memory: calculateMemoryUsage(stats), + network: calculateNetworkIO(stats), + blockIO: calculateBlockIO(stats) + }); + } catch (e) { + console.error(`Error getting stats for ${container.Id}:`, e); + } + } + + socket.send(JSON.stringify({ + type: 'metrics', + timestamp: new Date().toISOString(), + host: hostMetrics, + containers: containerStats + })); + } catch (error) { + console.error('Error sending metrics:', error); + } + }, 2000); + }; + + socket.onclose = () => { + console.log("WebSocket connection closed"); + if (intervalId) clearInterval(intervalId); + }; + + socket.onerror = (e) => { + console.error("WebSocket error:", e); + if (intervalId) clearInterval(intervalId); + }; + + return response; + } catch (error) { + console.error("WebSocket setup error:", error); + return new Response("Internal error", { status: 500 }); + } +}); + +// Helper functions to calculate stats +function calculateCPUPercent(stats: any): string { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + const cpuPercent = (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100; + return cpuPercent.toFixed(2); +} + +function calculateMemoryUsage(stats: any): { used: number; limit: number; percent: string } { + const used = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0); + const limit = stats.memory_stats.limit; + const percent = (used / limit * 100).toFixed(2); + return { used, limit, percent }; +} + +function calculateNetworkIO(stats: any): { rx: number; tx: number } { + const networks = stats.networks || {}; + let rx = 0, tx = 0; + for (const net of Object.values(networks) as any[]) { + rx += net.rx_bytes || 0; + tx += net.tx_bytes || 0; + } + return { rx, tx }; +} + +function calculateBlockIO(stats: any): { read: number; write: number } { + const io = stats.blkio_stats.io_service_bytes_recursive || []; + let read = 0, write = 0; + for (const entry of io) { + if (entry.op === 'Read') read += entry.value; + if (entry.op === 'Write') write += entry.value; + } + return { read, write }; +} diff --git a/supabase/migrations/20251020214036_8d0737a8-a881-40a2-9a58-7c0e261013ef.sql b/supabase/migrations/20251020214036_8d0737a8-a881-40a2-9a58-7c0e261013ef.sql new file mode 100644 index 0000000..d78e4b4 --- /dev/null +++ b/supabase/migrations/20251020214036_8d0737a8-a881-40a2-9a58-7c0e261013ef.sql @@ -0,0 +1,49 @@ +-- Fix search_path for security definer functions + +-- Drop and recreate update_updated_at_column with search_path +DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Recreate triggers +CREATE TRIGGER update_profiles_updated_at + BEFORE UPDATE ON public.profiles + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_global_settings_updated_at + BEFORE UPDATE ON public.global_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_container_settings_updated_at + BEFORE UPDATE ON public.container_settings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Drop and recreate handle_new_user with proper search_path +DROP FUNCTION IF EXISTS public.handle_new_user() CASCADE; + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.profiles (id, username) + VALUES ( + NEW.id, + COALESCE(NEW.raw_user_meta_data->>'username', split_part(NEW.email, '@', 1)) + ); + + INSERT INTO public.global_settings (user_id) + VALUES (NEW.id); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Recreate trigger +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); \ No newline at end of file