From 115d4a62e8845d37b31e2038e906d5688d28d3f8 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 11 Nov 2025 17:50:09 -0300 Subject: [PATCH] feat(agent): self-heal rustdesk remote access --- .env.example | 2 + .gitignore | 2 + apps/desktop/src/main.tsx | 386 +++++++++++++++++++++++++++++++------- convex/machines.ts | 68 ++++++- docs/OPERACAO-PRODUCAO.md | 4 + 5 files changed, 391 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 814fa87..b491561 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ BETTER_AUTH_SECRET=change-me-in-prod # Convex (dev server URL) NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 CONVEX_INTERNAL_URL=http://127.0.0.1:3210 +# Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional) +REMOTE_ACCESS_TOKEN_GRACE_MS=900000 # SQLite database (local dev) DATABASE_URL=file:./prisma/db.dev.sqlite diff --git a/.gitignore b/.gitignore index 7e0652c..bc5dc25 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ Screenshot*.png # Ignore NTFS ADS streams accidentally committed from Windows downloads *:*Zone.Identifier *:\:Zone.Identifier +# Infrastructure secrets +.ci.env diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index fb178fa..13e4cb4 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -64,6 +64,7 @@ type AgentConfig = { machineEmail?: string | null collaboratorEmail?: string | null collaboratorName?: string | null + provisioningCode?: string | null accessRole: "collaborator" | "manager" assignedUserId?: string | null assignedUserEmail?: string | null @@ -110,6 +111,50 @@ function normalizeUrl(value?: string | null, fallback = DEFAULT_APP_URL) { const appUrl = normalizeUrl(import.meta.env.VITE_APP_URL, DEFAULT_APP_URL) const apiBaseUrl = normalizeUrl(import.meta.env.VITE_API_BASE_URL, appUrl) +const RUSTDESK_SYNC_INTERVAL_MS = 60 * 60 * 1000 // 1h +const TOKEN_SELF_HEAL_DEBOUNCE_MS = 30 * 1000 + +function sanitizeEmail(value: string | null | undefined) { + const trimmed = (value ?? "").trim().toLowerCase() + return trimmed || null +} + +function isTokenRevokedMessage(input: string) { + const normalized = input.toLowerCase() + return ( + normalized.includes("token de dispositivo revogado") || + normalized.includes("token de dispositivo inválido") || + normalized.includes("token de dispositivo expirado") + ) +} + +function buildRemoteAccessPayload(info: RustdeskInfo | null) { + if (!info) return null + const payload: Record = { + provider: "RustDesk", + identifier: info.id, + url: `rustdesk://${info.id}`, + password: info.password, + } + if (info.installedVersion) { + payload.notes = `RustDesk ${info.installedVersion}` + } + return payload +} + +function buildRemoteAccessSnapshot(info: RustdeskInfo | null) { + const payload = buildRemoteAccessPayload(info) + if (!payload) return null + return { + ...payload, + lastVerifiedAt: Date.now(), + metadata: { + source: "raven-desktop", + version: info?.installedVersion ?? null, + }, + } +} + async function loadStore(): Promise { // Tenta usar uma pasta "data" ao lado do executável (ex.: C:\Raven\data) try { @@ -217,6 +262,9 @@ function App() { const [rustdeskInfo, setRustdeskInfo] = useState(null) const [isRustdeskProvisioning, setIsRustdeskProvisioning] = useState(false) const rustdeskBootstrapRef = useRef(false) + const rustdeskInfoRef = useRef(null) + const selfHealPromiseRef = useRef | null>(null) + const lastHealAtRef = useRef(0) const [provisioningCode, setProvisioningCode] = useState("") const [validatedCompany, setValidatedCompany] = useState<{ id: string; name: string; slug: string; tenantId: string } | null>(null) @@ -236,6 +284,193 @@ function App() { const emailRegex = useRef(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i) const isEmailValid = useMemo(() => emailRegex.current.test(collabEmail.trim()), [collabEmail]) + const ensureProfile = useCallback(async () => { + if (profile) return profile + const fresh = await invoke("collect_machine_profile") + setProfile(fresh) + return fresh + }, [profile]) + + const persistRegistration = useCallback( + async ( + data: MachineRegisterResponse, + opts: { + collaborator: { email: string; name?: string | null } + provisioningCode: string + company?: { name?: string | null; slug?: string | null; tenantId?: string | null } + } + ) => { + if (!store) throw new Error("Store não carregado") + const resolvedEmail = sanitizeEmail(opts.collaborator.email) + if (!resolvedEmail) { + throw new Error("Informe o e-mail do colaborador para concluir o registro") + } + const collaboratorNameNormalized = opts.collaborator.name?.trim() || null + const companyCtx = opts.company ?? { + name: config?.companyName ?? null, + slug: config?.companySlug ?? null, + tenantId: config?.tenantId ?? null, + } + await writeToken(store, data.machineToken) + setToken(data.machineToken) + + const nextConfig: AgentConfig = { + machineId: data.machineId, + tenantId: data.tenantId ?? companyCtx?.tenantId ?? config?.tenantId ?? null, + companySlug: data.companySlug ?? companyCtx?.slug ?? config?.companySlug ?? null, + companyName: companyCtx?.name ?? config?.companyName ?? companyName ?? null, + machineEmail: data.machineEmail ?? null, + collaboratorEmail: resolvedEmail, + collaboratorName: collaboratorNameNormalized, + provisioningCode: opts.provisioningCode, + accessRole: (data.persona ?? config?.accessRole ?? "collaborator").toLowerCase() === "manager" ? "manager" : "collaborator", + assignedUserId: data.assignedUserId ?? null, + assignedUserEmail: data.collaborator?.email ?? resolvedEmail, + assignedUserName: data.collaborator?.name ?? collaboratorNameNormalized, + apiBaseUrl, + appUrl, + createdAt: config?.createdAt ?? Date.now(), + lastSyncedAt: Date.now(), + expiresAt: data.expiresAt ?? null, + heartbeatIntervalSec: config?.heartbeatIntervalSec ?? null, + } + + setConfig(nextConfig) + setCompanyName(nextConfig.companyName ?? "") + setCollabEmail(resolvedEmail) + setCollabName(collaboratorNameNormalized ?? "") + setProvisioningCode(opts.provisioningCode) + await writeConfig(store, nextConfig) + + tokenVerifiedRef.current = true + setStatus("online") + setIsMachineActive(true) + setTokenValidationTick((tick) => tick + 1) + + try { + await invoke("start_machine_agent", { + baseUrl: apiBaseUrl, + token: data.machineToken, + status: "online", + intervalSeconds: nextConfig.heartbeatIntervalSec ?? 300, + }) + } catch (err) { + console.error("Falha ao reiniciar heartbeat", err) + } + + return nextConfig + }, + [store, config, companyName] + ) + + const reRegisterMachine = useCallback( + async (context: string) => { + if (!store) return false + const storedCode = (config?.provisioningCode ?? provisioningCode).trim().toLowerCase() + if (!storedCode) { + console.warn("[self-heal] Provisioning code absent; manual intervention required") + setError("Código de provisionamento ausente. Informe-o novamente para concluir o processo.") + return false + } + + const collaboratorEmail = sanitizeEmail(collabEmail) ?? config?.collaboratorEmail ?? config?.assignedUserEmail ?? null + if (!collaboratorEmail) { + console.warn("[self-heal] Collaborator email missing") + setError("Informe o e-mail do colaborador vinculado a esta dispositivo para continuar.") + return false + } + const collaboratorName = collabName.trim() || config?.collaboratorName || config?.assignedUserName || null + + let machineProfile: MachineProfile + try { + machineProfile = await ensureProfile() + } catch (profileError) { + console.error("[self-heal] Falha ao coletar perfil", profileError) + setError("Não foi possível coletar os dados da dispositivo para reprovisionar. Reabra o app como administrador.") + return false + } + + const metadataPayload: Record = { + inventory: machineProfile.inventory, + metrics: machineProfile.metrics, + collaborator: { + email: collaboratorEmail, + name: collaboratorName, + role: config?.accessRole ?? "collaborator", + }, + } + const snapshot = buildRemoteAccessSnapshot(rustdeskInfoRef.current) + if (snapshot) { + metadataPayload.remoteAccessSnapshot = snapshot + } + + const body = { + provisioningCode: storedCode, + hostname: machineProfile.hostname, + os: machineProfile.os, + macAddresses: machineProfile.mac_addresses, + serialNumbers: machineProfile.serial_numbers, + metadata: metadataPayload, + collaborator: { + email: collaboratorEmail, + name: collaboratorName ?? undefined, + }, + registeredBy: "desktop-agent", + } + + try { + const res = await fetch(`${apiBaseUrl}/api/machines/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (!res.ok) { + const text = await res.text() + console.error(`[self-heal] Reprovision failed (${context})`, text) + setError("Falha ao reprovisionar automaticamente. Revise o código e tente novamente.") + return false + } + const data = (await res.json()) as MachineRegisterResponse + await persistRegistration(data, { + collaborator: { email: collaboratorEmail, name: collaboratorName }, + provisioningCode: storedCode, + company: { + name: config?.companyName ?? validatedCompany?.name ?? null, + slug: config?.companySlug ?? validatedCompany?.slug ?? null, + tenantId: config?.tenantId ?? validatedCompany?.tenantId ?? null, + }, + }) + console.info(`[self-heal] Token regenerado (${context})`) + return true + } catch (error) { + console.error(`[self-heal] Erro inesperado (${context})`, error) + setError("Não foi possível reprovisionar automaticamente. Verifique a conexão e tente novamente.") + return false + } + }, + [store, config, provisioningCode, collabEmail, collabName, ensureProfile, persistRegistration, validatedCompany] + ) + + const attemptSelfHeal = useCallback( + async (context: string) => { + if (!store) return false + if (selfHealPromiseRef.current) { + return selfHealPromiseRef.current + } + const elapsed = Date.now() - lastHealAtRef.current + if (elapsed < TOKEN_SELF_HEAL_DEBOUNCE_MS && lastHealAtRef.current !== 0) { + return false + } + const promise = reRegisterMachine(context).finally(() => { + selfHealPromiseRef.current = null + lastHealAtRef.current = Date.now() + }) + selfHealPromiseRef.current = promise + return promise + }, + [store, reRegisterMachine] + ) + useEffect(() => { (async () => { try { @@ -268,10 +503,18 @@ function App() { ;(async () => { setIsValidatingToken(true) try { + const snapshot = buildRemoteAccessSnapshot(rustdeskInfoRef.current) + const heartbeatPayload: Record = { + machineToken: token, + status: "online", + } + if (snapshot) { + heartbeatPayload.metadata = { remoteAccessSnapshot: snapshot } + } const res = await fetch(`${apiBaseUrl}/api/machines/heartbeat`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ machineToken: token, status: "online" }), + body: JSON.stringify(heartbeatPayload), }) if (cancelled) return if (res.ok) { @@ -305,6 +548,12 @@ function App() { msg.includes("token de dispositivo revogado") || msg.includes("token de dispositivo expirado") if (isInvalid) { + const healed = await attemptSelfHeal("heartbeat") + if (cancelled) return + if (healed) { + setStatus("online") + return + } try { await store.delete("token"); await store.delete("config"); await store.save() } catch {} @@ -338,10 +587,10 @@ function App() { return () => { cancelled = true } - }, [store, token]) + }, [store, token, attemptSelfHeal]) - useEffect(() => { - if (!import.meta.env.DEV) return +useEffect(() => { + if (!import.meta.env.DEV) return function onKeyDown(event: KeyboardEvent) { const key = (event.key || "").toLowerCase() @@ -364,7 +613,11 @@ function App() { window.removeEventListener("keydown", onKeyDown) window.removeEventListener("contextmenu", onContextMenu) } - }, []) +}, []) + +useEffect(() => { + rustdeskInfoRef.current = rustdeskInfo +}, [rustdeskInfo]) useEffect(() => { if (!store || !config) return @@ -387,6 +640,12 @@ function App() { writeConfig(store, nextConfig).catch((err) => console.error("Falha ao atualizar colaborador", err)) }, [store, config, config?.collaboratorEmail, config?.collaboratorName, collabEmail, collabName]) + useEffect(() => { + if (!config?.provisioningCode) return + if (provisioningCode.trim()) return + setProvisioningCode(config.provisioningCode) + }, [config?.provisioningCode, provisioningCode]) + useEffect(() => { if (!store || !config) return const normalizedAppUrl = normalizeUrl(config.appUrl, appUrl) @@ -463,35 +722,45 @@ const resolvedAppUrl = useMemo(() => { return normalized }, [config?.appUrl]) -const syncRustdeskAccess = useCallback( - async (machineToken: string, info: RustdeskInfo) => { - if (!store || !machineToken) return - try { - const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - machineToken, - provider: "RustDesk", - identifier: info.id, - url: `rustdesk://${info.id}`, - password: info.password, - notes: info.installedVersion ? `RustDesk ${info.installedVersion}` : undefined, - }), - }) - if (!response.ok) { - const text = await response.text() - throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto") + const syncRustdeskAccess = useCallback( + async (machineToken: string, info: RustdeskInfo, allowRetry = true) => { + if (!store || !machineToken) return + const payload = buildRemoteAccessPayload(info) + if (!payload) return + try { + const response = await fetch(`${apiBaseUrl}/api/machines/remote-access`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineToken, ...payload }), + }) + if (!response.ok) { + const text = await response.text() + if (allowRetry && isTokenRevokedMessage(text)) { + const healed = await attemptSelfHeal("remote-access") + if (healed) { + const refreshedToken = (await readToken(store)) ?? machineToken + return syncRustdeskAccess(refreshedToken, info, false) + } + } + throw new Error(text.slice(0, 300) || "Falha ao registrar acesso remoto") + } + const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now() } + await writeRustdeskInfo(store, nextInfo) + setRustdeskInfo(nextInfo) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (allowRetry && isTokenRevokedMessage(message)) { + const healed = await attemptSelfHeal("remote-access") + if (healed) { + const refreshedToken = (await readToken(store)) ?? machineToken + return syncRustdeskAccess(refreshedToken, info, false) + } + } + console.error("Falha ao sincronizar acesso remoto com a plataforma", error) } - const nextInfo: RustdeskInfo = { ...info, lastSyncedAt: Date.now() } - await writeRustdeskInfo(store, nextInfo) - setRustdeskInfo(nextInfo) - } catch (error) { - console.error("Falha ao sincronizar acesso remoto com a plataforma", error) - } - }, - [store] -) + }, + [store, attemptSelfHeal] + ) const provisionRustdesk = useCallback( async (machineId: string, machineToken: string): Promise => { @@ -536,7 +805,7 @@ useEffect(() => { } if (rustdeskInfo && !isRustdeskProvisioning) { const lastSync = rustdeskInfo.lastSyncedAt ?? 0 - const needsSync = Date.now() - lastSync > 7 * 24 * 60 * 60 * 1000 + const needsSync = Date.now() - lastSync > RUSTDESK_SYNC_INTERVAL_MS if (needsSync) { syncRustdeskAccess(token, rustdeskInfo) } @@ -581,6 +850,10 @@ useEffect(() => { metrics: profile.metrics, collaborator: { email: normalizedEmail, name: normalizedName, role: "collaborator" }, } + const bootstrapSnapshot = buildRemoteAccessSnapshot(rustdeskInfoRef.current) + if (bootstrapSnapshot) { + metadataPayload.remoteAccessSnapshot = bootstrapSnapshot + } const payload = { provisioningCode: trimmedCode, @@ -604,45 +877,18 @@ useEffect(() => { } const data = (await res.json()) as MachineRegisterResponse - if (!store) throw new Error("Store ausente") - - await writeToken(store, data.machineToken) - - const cfg: AgentConfig = { - machineId: data.machineId, - tenantId: data.tenantId ?? validatedCompany.tenantId ?? null, - companySlug: data.companySlug ?? validatedCompany.slug ?? null, - companyName: validatedCompany.name, - machineEmail: data.machineEmail ?? null, - collaboratorEmail: collaboratorPayload.email, - collaboratorName: collaboratorPayload.name, - accessRole: "collaborator", - assignedUserId: data.assignedUserId ?? null, - assignedUserEmail: data.collaborator?.email ?? collaboratorPayload.email, - assignedUserName: data.collaborator?.name ?? collaboratorPayload.name, - apiBaseUrl, - appUrl, - createdAt: Date.now(), - lastSyncedAt: Date.now(), - expiresAt: data.expiresAt ?? null, - } - - await writeConfig(store, cfg) - setConfig(cfg) - setToken(data.machineToken) - setCompanyName(validatedCompany.name) + await persistRegistration(data, { + collaborator: collaboratorPayload, + provisioningCode: trimmedCode, + company: { + name: validatedCompany.name, + slug: validatedCompany.slug, + tenantId: validatedCompany.tenantId, + }, + }) await provisionRustdesk(data.machineId, data.machineToken) - await invoke("start_machine_agent", { - baseUrl: apiBaseUrl, - token: data.machineToken, - status: "online", - intervalSeconds: 300, - }) - setStatus("online") - tokenVerifiedRef.current = true - // Abre o sistema imediatamente após registrar (evita ficar com token inválido no fluxo antigo) try { await fetch(`${apiBaseUrl}/api/machines/sessions`, { diff --git a/convex/machines.ts b/convex/machines.ts index 9bacb86..cb68a11 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -183,7 +183,16 @@ function hashToken(token: string) { return toHex(sha256(token)) } -const REMOTE_ACCESS_TOKEN_GRACE_MS = 5 * 60 * 1000 +function getRemoteAccessTokenGraceMs() { + const fallback = 15 * 60 * 1000 + const raw = process.env["REMOTE_ACCESS_TOKEN_GRACE_MS"] + if (!raw) return fallback + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + return parsed +} + +const REMOTE_ACCESS_TOKEN_GRACE_MS = getRemoteAccessTokenGraceMs() async function getTokenRecord( ctx: MutationCtx | QueryCtx, @@ -777,6 +786,10 @@ export const heartbeat = mutation({ if (args.metadata && typeof args.metadata === "object") { Object.assign(metadataPatch, args.metadata as Record) } + const remoteAccessSnapshot = metadataPatch["remoteAccessSnapshot"] + if (remoteAccessSnapshot !== undefined) { + delete metadataPatch["remoteAccessSnapshot"] + } if (args.inventory && typeof args.inventory === "object") { metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record) } @@ -800,6 +813,10 @@ export const heartbeat = mutation({ metadata: mergedMetadata, }) + if (remoteAccessSnapshot) { + await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now) + } + await ctx.db.patch(token._id, { lastUsedAt: now, usageCount: (token.usageCount ?? 0) + 1, @@ -2149,6 +2166,55 @@ function normalizeRemoteAccessList(raw: unknown): RemoteAccessEntry[] { return entries } +async function upsertRemoteAccessSnapshotFromHeartbeat( + ctx: MutationCtx, + machine: Doc<"machines">, + snapshot: unknown, + timestamp: number +) { + const normalized = normalizeRemoteAccessEntry(snapshot) + if (!normalized) return + const provider = (normalized.provider ?? "Remote").trim() + const identifier = (normalized.identifier ?? "").trim() + if (!identifier) return + + const existingEntries = normalizeRemoteAccessList(machine.remoteAccess) + const idx = existingEntries.findIndex( + (entry) => entry.provider.toLowerCase() === provider.toLowerCase() && entry.identifier.toLowerCase() === identifier.toLowerCase() + ) + + const entryId = idx >= 0 ? existingEntries[idx].id : createRemoteAccessId() + const metadata = { + ...(normalized.metadata ?? {}), + snapshotSource: "heartbeat", + provider, + identifier, + lastVerifiedAt: timestamp, + } + + const updatedEntry: RemoteAccessEntry = { + id: entryId, + provider, + identifier, + url: normalized.url ?? null, + username: normalized.username ?? null, + password: normalized.password ?? null, + notes: normalized.notes ?? null, + lastVerifiedAt: timestamp, + metadata, + } + + const nextEntries = + idx >= 0 + ? existingEntries.map((entry, index) => (index === idx ? updatedEntry : entry)) + : [...existingEntries, updatedEntry] + + await ctx.db.patch(machine._id, { + remoteAccess: nextEntries, + updatedAt: timestamp, + }) +} + export const updateRemoteAccess = mutation({ args: { machineId: v.id("machines"), diff --git a/docs/OPERACAO-PRODUCAO.md b/docs/OPERACAO-PRODUCAO.md index f667cca..b23a61b 100644 --- a/docs/OPERACAO-PRODUCAO.md +++ b/docs/OPERACAO-PRODUCAO.md @@ -81,6 +81,9 @@ FLEET_SYNC_SECRET= # Conexões internas (Next.js -> Convex) # CONVEX_INTERNAL_URL deve apontar para o hostname/porta do serviço no Swarm. +# Self-heal do Raven (opcional) +REMOTE_ACCESS_TOKEN_GRACE_MS=900000 # 15 minutos para aceitar tokens recém-revogados + # Outros CONVEX_SYNC_SECRET=dev-sync-secret ALERTS_LOCAL_HOUR=8 @@ -94,6 +97,7 @@ SEED_TENANT_ID=tenant-atlas Atenção - `MAILER_SENDER_EMAIL` precisa de aspas se contiver espaços. - Em self‑hosted, NÃO usar `CONVEX_DEPLOYMENT`. +- O Raven armazena o código de provisionamento localmente. Se o token for revogado (reset/reinstalação), ele reprovisiona sozinho e reenfileira o acesso remoto do RustDesk — mantenha `REMOTE_ACCESS_TOKEN_GRACE_MS` alinhado ao seu SLA. ## Stack (Docker Swarm + Traefik) O arquivo do stack está versionado em `stack.yml`. Ele sobe: