// ci: trigger convex functions deploy import { mutation, query } from "./_generated/server" import { api } from "./_generated/api" import { ConvexError, v } from "convex/values" import { sha256 } from "@noble/hashes/sha256" import { randomBytes } from "@noble/hashes/utils" import type { Doc, Id } from "./_generated/dataModel" import type { MutationCtx } from "./_generated/server" const DEFAULT_TENANT_ID = "tenant-atlas" const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias type NormalizedIdentifiers = { macs: string[] serials: string[] } function getProvisioningSecret() { const secret = process.env["MACHINE_PROVISIONING_SECRET"] if (!secret) { throw new ConvexError("Provisionamento de máquinas não configurado") } return secret } function getTokenTtlMs(): number { const raw = process.env["MACHINE_TOKEN_TTL_MS"] if (!raw) return DEFAULT_TOKEN_TTL_MS const parsed = Number(raw) if (!Number.isFinite(parsed) || parsed < 60_000) { return DEFAULT_TOKEN_TTL_MS } return parsed } function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): NormalizedIdentifiers { const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase() const normalizeSerial = (value: string) => value.trim().toLowerCase() const macs = Array.from(new Set(macAddresses.map(normalizeMac).filter(Boolean))).sort() const serials = Array.from(new Set(serialNumbers.map(normalizeSerial).filter(Boolean))).sort() if (macs.length === 0 && serials.length === 0) { throw new ConvexError("Informe ao menos um identificador (MAC ou serial)") } return { macs, serials } } function toHex(input: Uint8Array) { return Array.from(input) .map((byte) => byte.toString(16).padStart(2, "0")) .join("") } function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) { const payload = JSON.stringify({ tenantId, companySlug: companySlug ?? null, hostname: hostname.trim().toLowerCase(), macs: ids.macs, serials: ids.serials, }) return toHex(sha256(payload)) } function hashToken(token: string) { return toHex(sha256(token)) } async function ensureCompany( ctx: MutationCtx, tenantId: string, companySlug?: string ): Promise<{ companyId?: Id<"companies">; companySlug?: string }> { if (!companySlug) return {} const company = await ctx.db .query("companies") .withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug)) .unique() if (!company) { throw new ConvexError("Empresa não encontrada para o tenant informado") } return { companyId: company._id, companySlug: company.slug } } async function getActiveToken( ctx: MutationCtx, tokenValue: string ): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> { const tokenHash = hashToken(tokenValue) const token = await ctx.db .query("machineTokens") .withIndex("by_token_hash", (q: any) => q.eq("tokenHash", tokenHash)) .unique() if (!token) { throw new ConvexError("Token de máquina inválido") } if (token.revoked) { throw new ConvexError("Token de máquina revogado") } if (token.expiresAt < Date.now()) { throw new ConvexError("Token de máquina expirado") } const machine = await ctx.db.get(token.machineId) if (!machine) { throw new ConvexError("Máquina não encontrada para o token fornecido") } return { token, machine } } function mergeMetadata(current: unknown, patch: Record) { if (!current || typeof current !== "object") return patch return { ...(current as Record), ...patch } } type PostureFinding = { kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL" message: string severity: "warning" | "critical" } async function createTicketForAlert( ctx: MutationCtx, tenantId: string, companyId: Id<"companies"> | undefined, subject: string, summary: string ) { const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev" const actor = await ctx.db .query("users") .withIndex("by_tenant_email", (q: any) => q.eq("tenantId", tenantId).eq("email", actorEmail)) .unique() if (!actor) return null // pick first category/subcategory if not configured const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q: any) => q.eq("tenantId", tenantId)).first() if (!category) return null const subcategory = await ctx.db .query("ticketSubcategories") .withIndex("by_category_order", (q: any) => q.eq("categoryId", category._id)) .first() if (!subcategory) return null try { const id = await ctx.runMutation(api.tickets.create, { actorId: actor._id, tenantId, subject, summary, priority: "Alta", channel: "Automação", queueId: undefined, requesterId: actor._id, assigneeId: undefined, categoryId: category._id, subcategoryId: subcategory._id, customFields: undefined, }) return id } catch (error) { console.error("[machines.alerts] Falha ao criar ticket:", error) return null } } async function evaluatePostureAndMaybeRaise( ctx: MutationCtx, machine: Doc<"machines">, args: { metrics?: any; inventory?: any; metadata?: any } ) { const findings: PostureFinding[] = [] // Janela temporal de CPU (5 minutos) const now = Date.now() const metrics = args.metrics ?? (args.metadata?.metrics ?? null) const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record) : {} const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow) ? (((metaObj as any).cpuWindow as Array).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage))) : [] const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000) const usage = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN) if (Number.isFinite(usage)) { window.push({ ts: now, usage }) } if (window.length > 0) { const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length if (avg >= 90) { findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" }) } } const inventory = args.inventory ?? (args.metadata?.inventory ?? null) if (inventory && typeof inventory === "object") { const services = (inventory as any).services if (Array.isArray(services)) { const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "") .split(/[\s,]+/) .map((s) => s.trim().toLowerCase()) .filter(Boolean) const criticalSet = new Set(criticalList) const firstDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running") if (firstDown) { const name = String(firstDown.name ?? firstDown.Name ?? "serviço") const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning" findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev }) } } const smart = (inventory as any).extended?.linux?.smart if (Array.isArray(smart)) { const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false) if (failing) { const model = failing?.model_name ?? failing?.model_family ?? "Disco" const serial = failing?.serial_number ?? failing?.device?.name ?? "—" const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})` findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" }) } } } // Persistir janela de CPU (limite de 120 amostras) const cpuWindowCapped = window.slice(-120) await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) }) if (!findings.length) return const record = { postureAlerts: findings, lastPostureAt: now, } const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record) : null const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0 await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return const subject = `Alerta de máquina: ${machine.hostname}` const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ") await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary) } export const register = mutation({ args: { provisioningSecret: v.string(), tenantId: v.optional(v.string()), companySlug: v.optional(v.string()), hostname: v.string(), os: v.object({ name: v.string(), version: v.optional(v.string()), architecture: v.optional(v.string()), }), macAddresses: v.array(v.string()), serialNumbers: v.array(v.string()), metadata: v.optional(v.any()), registeredBy: v.optional(v.string()), }, handler: async (ctx, args) => { const secret = getProvisioningSecret() if (args.provisioningSecret !== secret) { throw new ConvexError("Código de provisionamento inválido") } const tenantId = args.tenantId ?? DEFAULT_TENANT_ID const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const now = Date.now() const existing = await ctx.db .query("machines") .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) .first() let machineId: Id<"machines"> if (existing) { await ctx.db.patch(existing._id, { tenantId, companyId: companyId ?? existing.companyId, companySlug: companySlug ?? existing.companySlug, hostname: args.hostname, osName: args.os.name, osVersion: args.os.version, architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, metadata: args.metadata ? mergeMetadata(existing.metadata, { inventory: args.metadata }) : existing.metadata, lastHeartbeatAt: now, updatedAt: now, status: "online", registeredBy: args.registeredBy ?? existing.registeredBy, }) machineId = existing._id } else { machineId = await ctx.db.insert("machines", { tenantId, companyId, companySlug, hostname: args.hostname, osName: args.os.name, osVersion: args.os.version, architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, fingerprint, metadata: args.metadata ? { inventory: args.metadata } : undefined, lastHeartbeatAt: now, status: "online", createdAt: now, updatedAt: now, registeredBy: args.registeredBy, }) } const previousTokens = await ctx.db .query("machineTokens") .withIndex("by_machine", (q) => q.eq("machineId", machineId)) .collect() for (const token of previousTokens) { if (!token.revoked) { await ctx.db.patch(token._id, { revoked: true, lastUsedAt: now }) } } const tokenPlain = toHex(randomBytes(32)) const tokenHash = hashToken(tokenPlain) const expiresAt = now + getTokenTtlMs() await ctx.db.insert("machineTokens", { tenantId, machineId, tokenHash, expiresAt, revoked: false, createdAt: now, usageCount: 0, type: "machine", }) return { machineId, tenantId, companyId, companySlug, machineToken: tokenPlain, expiresAt, } }, }) export const upsertInventory = mutation({ args: { provisioningSecret: v.string(), tenantId: v.optional(v.string()), companySlug: v.optional(v.string()), hostname: v.string(), os: v.object({ name: v.string(), version: v.optional(v.string()), architecture: v.optional(v.string()), }), macAddresses: v.array(v.string()), serialNumbers: v.array(v.string()), inventory: v.optional(v.any()), metrics: v.optional(v.any()), registeredBy: v.optional(v.string()), }, handler: async (ctx, args) => { const secret = getProvisioningSecret() if (args.provisioningSecret !== secret) { throw new ConvexError("Código de provisionamento inválido") } const tenantId = args.tenantId ?? DEFAULT_TENANT_ID const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers) const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers) const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug) const now = Date.now() const metadataPatch = mergeMetadata({}, { ...(args.inventory ? { inventory: args.inventory } : {}), ...(args.metrics ? { metrics: args.metrics } : {}), }) const existing = await ctx.db .query("machines") .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) .first() let machineId: Id<"machines"> if (existing) { await ctx.db.patch(existing._id, { tenantId, companyId: companyId ?? existing.companyId, companySlug: companySlug ?? existing.companySlug, hostname: args.hostname, osName: args.os.name, osVersion: args.os.version, architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, metadata: mergeMetadata(existing.metadata, metadataPatch), lastHeartbeatAt: now, updatedAt: now, status: args.metrics ? "online" : existing.status ?? "unknown", registeredBy: args.registeredBy ?? existing.registeredBy, }) machineId = existing._id } else { machineId = await ctx.db.insert("machines", { tenantId, companyId, companySlug, hostname: args.hostname, osName: args.os.name, osVersion: args.os.version, architecture: args.os.architecture, macAddresses: identifiers.macs, serialNumbers: identifiers.serials, fingerprint, metadata: metadataPatch, lastHeartbeatAt: now, status: args.metrics ? "online" : "unknown", createdAt: now, updatedAt: now, registeredBy: args.registeredBy, }) } // Evaluate posture/alerts based on provided metrics/inventory const machine = (await ctx.db.get(machineId)) as Doc<"machines"> await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory }) return { machineId, tenantId, companyId, companySlug, status: args.metrics ? "online" : "unknown", } }, }) export const heartbeat = mutation({ args: { machineToken: v.string(), status: v.optional(v.string()), hostname: v.optional(v.string()), os: v.optional( v.object({ name: v.string(), version: v.optional(v.string()), architecture: v.optional(v.string()), }) ), metrics: v.optional(v.any()), inventory: v.optional(v.any()), metadata: v.optional(v.any()), }, handler: async (ctx, args) => { const { machine, token } = await getActiveToken(ctx, args.machineToken) const now = Date.now() const mergedMetadata = mergeMetadata(machine.metadata, { ...(args.metadata ?? {}), ...(args.metrics ? { metrics: args.metrics } : {}), ...(args.inventory ? { inventory: args.inventory } : {}), }) await ctx.db.patch(machine._id, { hostname: args.hostname ?? machine.hostname, osName: args.os?.name ?? machine.osName, osVersion: args.os?.version ?? machine.osVersion, architecture: args.os?.architecture ?? machine.architecture, lastHeartbeatAt: now, updatedAt: now, status: args.status ?? "online", metadata: mergedMetadata, }) await ctx.db.patch(token._id, { lastUsedAt: now, usageCount: (token.usageCount ?? 0) + 1, expiresAt: now + getTokenTtlMs(), }) // Evaluate posture/alerts & optionally create ticket const fresh = (await ctx.db.get(machine._id)) as Doc<"machines"> await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata }) return { ok: true, machineId: machine._id, expiresAt: now + getTokenTtlMs(), } }, }) export const resolveToken = mutation({ args: { machineToken: v.string(), }, handler: async (ctx, args) => { const { machine, token } = await getActiveToken(ctx, args.machineToken) const now = Date.now() await ctx.db.patch(token._id, { lastUsedAt: now, usageCount: (token.usageCount ?? 0) + 1, }) return { machine: { _id: machine._id, tenantId: machine.tenantId, companyId: machine.companyId, companySlug: machine.companySlug, hostname: machine.hostname, osName: machine.osName, osVersion: machine.osVersion, architecture: machine.architecture, authUserId: machine.authUserId, authEmail: machine.authEmail, status: machine.status, lastHeartbeatAt: machine.lastHeartbeatAt, metadata: machine.metadata, }, token: { expiresAt: token.expiresAt, lastUsedAt: token.lastUsedAt ?? null, usageCount: token.usageCount ?? 0, }, } }, }) export const listByTenant = query({ args: { tenantId: v.optional(v.string()), includeMetadata: v.optional(v.boolean()), }, handler: async (ctx, args) => { const tenantId = args.tenantId ?? DEFAULT_TENANT_ID const includeMetadata = Boolean(args.includeMetadata) const now = Date.now() const machines = await ctx.db .query("machines") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() return Promise.all( machines.map(async (machine) => { const tokens = await ctx.db .query("machineTokens") .withIndex("by_machine", (q) => q.eq("machineId", machine._id)) .collect() const activeToken = tokens.find((token) => !token.revoked && token.expiresAt > now) ?? null const derivedStatus = machine.status ?? (machine.lastHeartbeatAt && now - machine.lastHeartbeatAt <= 5 * 60 * 1000 ? "online" : machine.lastHeartbeatAt ? "offline" : "unknown") const metadata = includeMetadata ? (machine.metadata ?? null) : null let metrics: Record | null = null let inventory: Record | null = null let postureAlerts: Array> | null = null let lastPostureAt: number | null = null if (metadata && typeof metadata === "object") { const metaRecord = metadata as Record if (metaRecord.metrics && typeof metaRecord.metrics === "object") { metrics = metaRecord.metrics as Record } if (metaRecord.inventory && typeof metaRecord.inventory === "object") { inventory = metaRecord.inventory as Record } if (Array.isArray(metaRecord.postureAlerts)) { postureAlerts = metaRecord.postureAlerts as Array> } if (typeof metaRecord.lastPostureAt === "number") { lastPostureAt = metaRecord.lastPostureAt as number } } return { id: machine._id, tenantId: machine.tenantId, hostname: machine.hostname, companyId: machine.companyId ?? null, companySlug: machine.companySlug ?? null, osName: machine.osName, osVersion: machine.osVersion ?? null, architecture: machine.architecture ?? null, macAddresses: machine.macAddresses, serialNumbers: machine.serialNumbers, authUserId: machine.authUserId ?? null, authEmail: machine.authEmail ?? null, status: derivedStatus, lastHeartbeatAt: machine.lastHeartbeatAt ?? null, heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, createdAt: machine.createdAt, updatedAt: machine.updatedAt, token: activeToken ? { expiresAt: activeToken.expiresAt, lastUsedAt: activeToken.lastUsedAt ?? null, usageCount: activeToken.usageCount ?? 0, } : null, metrics, inventory, postureAlerts, lastPostureAt, } }) ) }, }) export const linkAuthAccount = mutation({ args: { machineId: v.id("machines"), authUserId: v.string(), authEmail: v.string(), }, handler: async (ctx, args) => { const machine = await ctx.db.get(args.machineId) if (!machine) { throw new ConvexError("Máquina não encontrada") } await ctx.db.patch(machine._id, { authUserId: args.authUserId, authEmail: args.authEmail, updatedAt: Date.now(), }) return { ok: true } }, })