From 054c94dc7fc281eb1b322a25e262c52b59e31094 Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:19:56 -0700 Subject: [PATCH] feat: add ! kubectl command to terminal + restrict RBAC (no secrets/configmaps) --- app/api/kubectl/route.ts | 73 ++++++++++++++++++++++++++++++ components/InteractiveTerminal.tsx | 51 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 app/api/kubectl/route.ts diff --git a/app/api/kubectl/route.ts b/app/api/kubectl/route.ts new file mode 100644 index 0000000..24ef101 --- /dev/null +++ b/app/api/kubectl/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from 'next/server' +import { exec } from 'child_process' +import { promisify } from 'util' + +const execAsync = promisify(exec) + +// Blocked resources (no read access from terminal) +const BLOCKED_RESOURCES = [ + 'secret', + 'secrets', + 'configmap', + 'configmaps', + 'certificate', + 'certificates', + 'key', + 'keys', + 'token', + 'tokens', + 'serviceaccount', + 'serviceaccounts', +] + +export async function POST(request: NextRequest) { + try { + const { command } = await request.json() + + if (!command || typeof command !== 'string') { + return NextResponse.json({ error: 'Invalid command' }, { status: 400 }) + } + + // Must start with kubectl (enforced by terminal, but double-check) + if (!command.trim().startsWith('kubectl ')) { + return NextResponse.json({ error: 'Only kubectl commands allowed' }, { status: 403 }) + } + + // Check for blocked resources + const cmdLower = command.toLowerCase() + for (const blocked of BLOCKED_RESOURCES) { + if (cmdLower.includes(blocked)) { + return NextResponse.json( + { error: `Access denied: cannot read '${blocked}'` }, + { status: 403 } + ) + } + } + + // Execute kubectl (uses homelab-agent read-only context) + const { stdout, stderr } = await execAsync(command, { + timeout: 10000, // 10s timeout + maxBuffer: 1024 * 1024, // 1MB max output + }) + + return NextResponse.json({ + output: stdout || stderr, + status: 'success', + }) + } catch (error) { + const err = error as Error & { code?: number } + + // Timeout or execution error + if (err.message.includes('ETIMEDOUT')) { + return NextResponse.json( + { error: 'Command timeout (10s limit)' }, + { status: 504 } + ) + } + + return NextResponse.json( + { error: err.message || 'Command failed' }, + { status: 500 } + ) + } +} diff --git a/components/InteractiveTerminal.tsx b/components/InteractiveTerminal.tsx index ebc02e5..a82cd89 100644 --- a/components/InteractiveTerminal.tsx +++ b/components/InteractiveTerminal.tsx @@ -35,7 +35,9 @@ function useTheme() { } const commands: Record = { - help: `QUESTIONS TO EXPLORE: + help: `COMMANDS: help, /clear, ! kubectl + +QUESTIONS TO EXPLORE: Homelab & Infrastructure • Did you build Kubernetes from scratch? How hard? @@ -173,6 +175,53 @@ export function InteractiveTerminal() { return } + // Handle ! kubectl commands + if (trimmed.startsWith('!')) { + const kubectlCmd = trimmed.slice(1).trim() + if (!kubectlCmd.startsWith('kubectl ')) { + setHistory(prev => [...prev, { cmd: trimmed, output: 'Error: only kubectl commands supported (e.g. ! kubectl get pods)' }]) + setInput('') + return + } + + setIsLoading(true) + setInput('') + setHistory(prev => [...prev, { cmd: trimmed, output: '', isStreaming: true }]) + const entryIndex = history.length + + try { + const response = await fetch('/api/kubectl', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: kubectlCmd }), + }) + + const data = await response.json() + setHistory(prev => { + const updated = [...prev] + const entry = updated[entryIndex] + if (entry) { + entry.output = data.error || data.output || 'No output' + entry.isStreaming = false + } + return updated + }) + } catch (err) { + setHistory(prev => { + const updated = [...prev] + const entry = updated[entryIndex] + if (entry) { + entry.output = `Error: ${err instanceof Error ? err.message : 'Request failed'}` + entry.isStreaming = false + } + return updated + }) + } finally { + setIsLoading(false) + } + return + } + // Check for built-in commands if (commands[lowerCmd]) { setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])