feat: implement Remote WinBox worker, API, frontend integration, OpenBao persistence, and supporting docs

This commit is contained in:
Jason Staack
2026-03-14 09:05:14 -05:00
parent 7af08276ea
commit 970501e453
86 changed files with 3440 additions and 3764 deletions

View File

@@ -0,0 +1,295 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Globe, X, Loader2, RefreshCw, Maximize2, Minimize2 } from 'lucide-react'
import { remoteWinboxApi, type RemoteWinBoxSession } from '@/lib/api'
interface RemoteWinBoxButtonProps {
tenantId: string
deviceId: string
}
type State = 'idle' | 'requesting' | 'connecting' | 'active' | 'closing' | 'terminated' | 'failed'
export function RemoteWinBoxButton({ tenantId, deviceId }: RemoteWinBoxButtonProps) {
const [state, setState] = useState<State>('idle')
const [session, setSession] = useState<RemoteWinBoxSession | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState(false)
const [countdown, setCountdown] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const queryClient = useQueryClient()
// Check for existing active sessions on mount
const { data: existingSessions } = useQuery({
queryKey: ['remote-winbox-sessions', tenantId, deviceId],
queryFn: () => remoteWinboxApi.list(tenantId, deviceId),
refetchOnWindowFocus: false,
})
useEffect(() => {
if (existingSessions && state === 'idle') {
const active = existingSessions.find(
(s) => s.status === 'active' || s.status === 'creating',
)
if (active) {
setSession(active)
setState(active.status === 'active' ? 'active' : 'connecting')
}
}
}, [existingSessions, state])
// Poll session status while connecting
useEffect(() => {
if (state !== 'connecting' || !session) return
const poll = setInterval(async () => {
try {
const updated = await remoteWinboxApi.get(tenantId, deviceId, session.session_id)
setSession(updated)
if (updated.status === 'active') {
setState('active')
} else if (updated.status === 'failed') {
setState('failed')
setError('Session failed to provision')
} else if (updated.status === 'terminated') {
setState('terminated')
}
} catch {
// ignore transient polling errors
}
}, 2000)
pollRef.current = poll
return () => clearInterval(poll)
}, [state, session, tenantId, deviceId])
// Countdown timer for session expiry
useEffect(() => {
if (state !== 'active' || !session?.expires_at) {
setCountdown(null)
return
}
const tick = () => {
const remaining = Math.max(0, new Date(session.expires_at).getTime() - Date.now())
if (remaining <= 0) {
setCountdown('Expired')
setState('terminated')
return
}
const mins = Math.floor(remaining / 60000)
const secs = Math.floor((remaining % 60000) / 1000)
setCountdown(`${mins}:${secs.toString().padStart(2, '0')}`)
}
tick()
const interval = setInterval(tick, 1000)
return () => clearInterval(interval)
}, [state, session?.expires_at])
const createMutation = useMutation({
mutationFn: () => remoteWinboxApi.create(tenantId, deviceId),
onSuccess: (data) => {
setSession(data)
if (data.status === 'active') {
setState('active')
} else {
setState('connecting')
}
},
onError: (err: any) => {
setState('failed')
setError(err.response?.data?.detail || 'Failed to create session')
},
})
const closeMutation = useMutation({
mutationFn: () => {
if (!session) throw new Error('No session')
return remoteWinboxApi.delete(tenantId, deviceId, session.session_id)
},
onSuccess: () => {
setState('idle')
setSession(null)
setError(null)
queryClient.invalidateQueries({ queryKey: ['remote-winbox-sessions', tenantId, deviceId] })
},
onError: (err: any) => {
setState('failed')
setError(err.response?.data?.detail || 'Failed to close session')
},
})
const handleOpen = useCallback(() => {
setState('requesting')
setError(null)
createMutation.mutate()
}, [createMutation])
const handleClose = useCallback(() => {
setState('closing')
closeMutation.mutate()
}, [closeMutation])
const handleRetry = useCallback(() => {
setSession(null)
setError(null)
handleOpen()
}, [handleOpen])
const handleReset = useCallback(async () => {
try {
const sessions = await remoteWinboxApi.list(tenantId, deviceId)
for (const s of sessions) {
if (s.status === 'active' || s.status === 'creating' || s.status === 'grace') {
await remoteWinboxApi.delete(tenantId, deviceId, s.session_id)
}
}
} catch {
// ignore cleanup errors
}
setState('idle')
setSession(null)
setError(null)
queryClient.invalidateQueries({ queryKey: ['remote-winbox-sessions', tenantId, deviceId] })
}, [tenantId, deviceId, queryClient])
// Build iframe URL: load Xpra HTML5 client directly via nginx /xpra/{port}/ proxy
// path= tells the Xpra HTML5 client where to open the WebSocket connection
const iframeSrc = session?.session_id && session?.xpra_ws_port
? `/xpra/${session.xpra_ws_port}/index.html?path=/xpra/${session.xpra_ws_port}/&keyboard=false&floating_menu=false&sharing=false&clipboard=false`
: null
// Idle / Failed / Terminated states — show button
if (state === 'idle' || state === 'failed' || state === 'terminated') {
return (
<div>
<div className="flex items-center gap-2">
<button
onClick={handleOpen}
disabled={createMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
{createMutation.isPending ? 'Starting...' : 'Remote WinBox'}
</button>
<button
onClick={handleReset}
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
title="Reset all remote WinBox sessions for this device"
>
<RefreshCw className="h-4 w-4" />
Reset
</button>
</div>
{state === 'failed' && error && (
<div className="mt-2 flex items-center gap-2">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{state === 'terminated' && (
<p className="mt-2 text-sm text-muted-foreground">Session ended</p>
)}
</div>
)
}
// Requesting / Connecting — spinner
if (state === 'requesting' || state === 'connecting') {
return (
<div className="rounded-md border p-4 space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm font-medium">
{state === 'requesting' ? 'Requesting session...' : 'Provisioning WinBox container...'}
</p>
</div>
<p className="text-xs text-muted-foreground">This may take a few seconds</p>
</div>
)
}
// Closing
if (state === 'closing') {
return (
<div className="rounded-md border p-4">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<p className="text-sm font-medium">Closing session...</p>
</div>
</div>
)
}
// Active — show iframe
if (state === 'active' && iframeSrc) {
return (
<div
className={
expanded
? 'fixed inset-0 z-50 bg-background flex flex-col'
: 'rounded-md border flex flex-col'
}
>
{/* Header bar */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/50">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Remote WinBox</span>
{countdown && (
<span className="text-xs text-muted-foreground">
Expires in {countdown}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setExpanded(!expanded)}
className="p-1.5 rounded hover:bg-accent"
title={expanded ? 'Minimize' : 'Maximize'}
>
{expanded ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</button>
<button
onClick={handleClose}
disabled={closeMutation.isPending}
className="p-1.5 rounded hover:bg-accent disabled:opacity-50"
title="Close session"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Xpra iframe */}
<iframe
src={iframeSrc}
className={expanded ? 'flex-1 w-full' : 'w-full h-[600px]'}
style={{ border: 'none' }}
allow="clipboard-read; clipboard-write"
title="Remote WinBox Session"
/>
</div>
)
}
// Active but no iframe URL (missing xpra_ws_port) — show reset option
return (
<div className="rounded-md border p-4 space-y-2">
<p className="text-sm text-destructive">Session active but display unavailable</p>
<button
onClick={handleReset}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md border border-input bg-background hover:bg-accent text-sm"
>
<RefreshCw className="h-3 w-3" />
Reset
</button>
</div>
)
}

View File

@@ -3,9 +3,9 @@ import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuth, isSuperAdmin, isTenantAdmin } from '@/lib/auth'
import { authApi } from '@/lib/api'
import { getSMTPSettings, updateSMTPSettings, testSMTPSettings } from '@/lib/settingsApi'
import { getSMTPSettings, updateSMTPSettings, testSMTPSettings, clearWinboxSessions } from '@/lib/settingsApi'
import { SMTP_PRESETS } from '@/lib/smtpPresets'
import { Settings, User, Shield, Info, Key, Lock, ChevronRight, Download, Trash2, AlertTriangle, Mail } from 'lucide-react'
import { Settings, User, Shield, Info, Key, Lock, ChevronRight, Download, Trash2, AlertTriangle, Mail, Monitor } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
@@ -149,6 +149,34 @@ export function SettingsPage() {
</div>
)}
{/* Maintenance — super_admin only */}
{isSuperAdmin(user) && (
<div className="rounded-lg border border-border bg-surface px-4 py-3 space-y-1">
<SectionHeader icon={Monitor} title="Maintenance" />
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-text-primary">Clear WinBox Sessions</span>
<p className="text-xs text-text-muted">Remove stale sessions and rate limits from Redis</p>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
const result = await clearWinboxSessions()
toast.success(`Cleared ${result.deleted} key${result.deleted !== 1 ? 's' : ''} from Redis`)
} catch {
toast.error('Failed to clear WinBox sessions')
}
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Clear
</Button>
</div>
</div>
)}
{/* System Email (SMTP) — super_admin only */}
{isSuperAdmin(user) && <SMTPSettingsSection />}

View File

@@ -968,6 +968,59 @@ export const remoteAccessApi = {
.then((r) => r.data),
}
// ─── Remote WinBox (Browser) ─────────────────────────────────────────────────
export interface RemoteWinBoxSession {
session_id: string
status: 'creating' | 'active' | 'grace' | 'terminating' | 'terminated' | 'failed'
websocket_path?: string
xpra_ws_port?: number
idle_timeout_seconds: number
max_lifetime_seconds: number
expires_at: string
max_expires_at: string
created_at?: string
}
export const remoteWinboxApi = {
create: (tenantId: string, deviceId: string, opts?: {
idle_timeout_seconds?: number
max_lifetime_seconds?: number
}) =>
api
.post<RemoteWinBoxSession>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
opts || {},
)
.then((r) => r.data),
get: (tenantId: string, deviceId: string, sessionId: string) =>
api
.get<RemoteWinBoxSession>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
)
.then((r) => r.data),
list: (tenantId: string, deviceId: string) =>
api
.get<RemoteWinBoxSession[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions`,
)
.then((r) => r.data),
delete: (tenantId: string, deviceId: string, sessionId: string) =>
api
.delete(
`/api/tenants/${tenantId}/devices/${deviceId}/winbox-remote-sessions/${sessionId}`,
)
.then((r) => r.data),
getWebSocketUrl: (sessionPath: string) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${sessionPath}`
},
}
// ─── Config History ─────────────────────────────────────────────────────────
export interface ConfigChangeEntry {

View File

@@ -10,7 +10,7 @@
* - localStorage and sessionStorage are NEVER used for any key material.
*/
const DB_NAME = 'mikrotik-portal-keys';
const DB_NAME = 'the-other-dude-keys';
const DB_VERSION = 1;
const STORE_NAME = 'secret-keys';

View File

@@ -28,6 +28,11 @@ export async function updateSMTPSettings(data: {
await api.put('/api/settings/smtp', data)
}
export async function clearWinboxSessions(): Promise<{ status: string; deleted: number }> {
const res = await api.delete('/api/settings/winbox-sessions')
return res.data
}
export async function testSMTPSettings(data: {
to: string
smtp_host?: string

View File

@@ -57,6 +57,7 @@ import { useSimpleConfigMode } from '@/hooks/useSimpleConfig'
import { SimpleModeToggle } from '@/components/simple-config/SimpleModeToggle'
import { SimpleConfigView } from '@/components/simple-config/SimpleConfigView'
import { WinBoxButton } from '@/components/fleet/WinBoxButton'
import { RemoteWinBoxButton } from '@/components/fleet/RemoteWinBoxButton'
import { SSHTerminal } from '@/components/fleet/SSHTerminal'
export const Route = createFileRoute(
@@ -456,7 +457,10 @@ function DeviceDetailPage() {
{user?.role !== 'viewer' && (
<div className="flex gap-2">
{device.routeros_version !== null && (
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
<>
<WinBoxButton tenantId={tenantId} deviceId={deviceId} />
<RemoteWinBoxButton tenantId={tenantId} deviceId={deviceId} />
</>
)}
<SSHTerminal tenantId={tenantId} deviceId={deviceId} deviceName={device.hostname} />
</div>

View File

@@ -8,7 +8,7 @@ setup('authenticate', async ({ page }) => {
// Use legacy-auth test user (no SRP/Secret Key required)
await page.getByLabel(/email/i).fill(
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev'
process.env.TEST_ADMIN_EMAIL || 'e2e-test@the-other-dude.dev'
)
await page.getByLabel(/password/i).fill(
process.env.TEST_ADMIN_PASSWORD || 'admin123'

View File

@@ -28,7 +28,7 @@ test.describe('Login Flow', () => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login(
process.env.TEST_ADMIN_EMAIL || 'e2e-test@mikrotik-portal.dev',
process.env.TEST_ADMIN_EMAIL || 'e2e-test@the-other-dude.dev',
process.env.TEST_ADMIN_PASSWORD || 'admin123'
)
// Legacy auth user may trigger SRP upgrade dialog -- handle it