feat: add ! kubectl command to terminal + restrict RBAC (no secrets/configmaps)
Build & Push Portfolio Image / build-push (push) Successful in 3m29s
Build & Push Portfolio Image / build-push (push) Successful in 3m29s
This commit is contained in:
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ function useTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commands: Record<string, string> = {
|
const commands: Record<string, string> = {
|
||||||
help: `QUESTIONS TO EXPLORE:
|
help: `COMMANDS: help, /clear, ! kubectl
|
||||||
|
|
||||||
|
QUESTIONS TO EXPLORE:
|
||||||
|
|
||||||
Homelab & Infrastructure
|
Homelab & Infrastructure
|
||||||
• Did you build Kubernetes from scratch? How hard?
|
• Did you build Kubernetes from scratch? How hard?
|
||||||
@@ -173,6 +175,53 @@ export function InteractiveTerminal() {
|
|||||||
return
|
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
|
// Check for built-in commands
|
||||||
if (commands[lowerCmd]) {
|
if (commands[lowerCmd]) {
|
||||||
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
|
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
|
||||||
|
|||||||
Reference in New Issue
Block a user