feat: adicionar painel de máquinas e autenticação por agente
This commit is contained in:
parent
e2a5b560b1
commit
ee18619519
52 changed files with 7598 additions and 1 deletions
184
src/app/api/integrations/fleet/hosts/route.ts
Normal file
184
src/app/api/integrations/fleet/hosts/route.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
|
||||
const fleetHostSchema = z.object({
|
||||
host: z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
platform: z.string().optional(),
|
||||
os_version: z.string().optional(),
|
||||
hardware_model: z.string().optional(),
|
||||
hardware_serial: z.string().optional(),
|
||||
hardware_uuid: z.string().optional(),
|
||||
uuid: z.string().optional(),
|
||||
device_id: z.string().optional(),
|
||||
primary_ip: z.string().optional(),
|
||||
public_ip: z.string().optional(),
|
||||
primary_mac: z.string().optional(),
|
||||
macs: z.string().optional(),
|
||||
serial_number: z.string().optional(),
|
||||
memory: z.number().optional(),
|
||||
cpu_type: z.string().optional(),
|
||||
cpu_physical_cores: z.number().optional(),
|
||||
cpu_logical_cores: z.number().optional(),
|
||||
hardware_vendor: z.string().optional(),
|
||||
computer_name: z.string().optional(),
|
||||
detail_updated_at: z.string().optional(),
|
||||
platform_like: z.string().optional(),
|
||||
osquery_version: z.string().optional(),
|
||||
team_id: z.number().optional(),
|
||||
software: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
labels: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.transform((value) => value ?? {}),
|
||||
})
|
||||
|
||||
function extractMacs(host: z.infer<typeof fleetHostSchema>["host"]) {
|
||||
const macs = new Set<string>()
|
||||
const append = (input?: string | null) => {
|
||||
if (!input) return
|
||||
input
|
||||
.split(/[\s,]+/)
|
||||
.map((mac) => mac.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((mac) => macs.add(mac))
|
||||
}
|
||||
append(host.primary_mac)
|
||||
append(host.macs)
|
||||
return Array.from(macs)
|
||||
}
|
||||
|
||||
function extractSerials(host: z.infer<typeof fleetHostSchema>["host"]) {
|
||||
return [
|
||||
host.hardware_serial,
|
||||
host.hardware_uuid,
|
||||
host.uuid,
|
||||
host.serial_number,
|
||||
host.device_id,
|
||||
]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const fleetSecret = env.FLEET_SYNC_SECRET ?? env.MACHINE_PROVISIONING_SECRET
|
||||
if (!fleetSecret) {
|
||||
return NextResponse.json({ error: "Sincronização Fleet não configurada" }, { status: 500 })
|
||||
}
|
||||
|
||||
const providedSecret = request.headers.get("x-fleet-secret") ?? request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
|
||||
if (!providedSecret || providedSecret !== fleetSecret) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
const raw = await request.json()
|
||||
parsed = fleetHostSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const host = parsed.host
|
||||
const hostname = host.hostname ?? host.computer_name ?? host.display_name
|
||||
if (!hostname) {
|
||||
return NextResponse.json({ error: "Host sem hostname válido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const macAddresses = extractMacs(host)
|
||||
const serialNumbers = extractSerials(host)
|
||||
|
||||
if (macAddresses.length === 0 && serialNumbers.length === 0) {
|
||||
return NextResponse.json({ error: "Host sem identificadores de hardware (MAC ou serial)" }, { status: 400 })
|
||||
}
|
||||
|
||||
const osInfo = {
|
||||
name: host.os_version ?? host.platform ?? "desconhecido",
|
||||
version: host.os_version,
|
||||
architecture: host.platform_like,
|
||||
}
|
||||
|
||||
const inventory = {
|
||||
fleet: {
|
||||
id: host.id,
|
||||
teamId: host.team_id,
|
||||
detailUpdatedAt: host.detail_updated_at,
|
||||
osqueryVersion: host.osquery_version,
|
||||
},
|
||||
hardware: {
|
||||
vendor: host.hardware_vendor,
|
||||
model: host.hardware_model,
|
||||
serial: host.hardware_serial ?? host.serial_number,
|
||||
cpuType: host.cpu_type,
|
||||
physicalCores: host.cpu_physical_cores,
|
||||
logicalCores: host.cpu_logical_cores,
|
||||
memoryBytes: host.memory,
|
||||
},
|
||||
network: {
|
||||
primaryIp: host.primary_ip,
|
||||
publicIp: host.public_ip,
|
||||
macAddresses,
|
||||
},
|
||||
labels: host.labels,
|
||||
software: host.software?.slice(0, 50).map((item) => ({
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
source: item.source,
|
||||
})),
|
||||
}
|
||||
|
||||
const metrics = {
|
||||
memoryBytes: host.memory,
|
||||
cpuPhysicalCores: host.cpu_physical_cores,
|
||||
cpuLogicalCores: host.cpu_logical_cores,
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.machines.upsertInventory, {
|
||||
provisioningSecret: fleetSecret,
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
hostname,
|
||||
companySlug: undefined,
|
||||
os: osInfo,
|
||||
macAddresses,
|
||||
serialNumbers,
|
||||
inventory,
|
||||
metrics,
|
||||
registeredBy: "fleet",
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, machineId: result.machineId, status: result.status })
|
||||
} catch (error) {
|
||||
console.error("[fleet.hosts] Falha ao sincronizar inventário", error)
|
||||
return NextResponse.json({ error: "Falha ao sincronizar inventário" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
51
src/app/api/machines/heartbeat/route.ts
Normal file
51
src/app/api/machines/heartbeat/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { env } from "@/lib/env"
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
hostname: z.string().optional(),
|
||||
os: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
version: z.string().optional(),
|
||||
architecture: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
metrics: z.record(z.string(), z.unknown()).optional(),
|
||||
inventory: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = heartbeatSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const response = await client.mutation(api.machines.heartbeat, payload)
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
console.error("[machines.heartbeat] Falha ao registrar heartbeat", error)
|
||||
return NextResponse.json({ error: "Falha ao registrar heartbeat" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
94
src/app/api/machines/register/route.ts
Normal file
94
src/app/api/machines/register/route.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
provisioningSecret: z.string().min(1),
|
||||
tenantId: z.string().optional(),
|
||||
companySlug: z.string().optional(),
|
||||
hostname: z.string().min(1),
|
||||
os: z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().optional(),
|
||||
architecture: z.string().optional(),
|
||||
}),
|
||||
macAddresses: z.array(z.string()).default([]),
|
||||
serialNumbers: z.array(z.string()).default([]),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
registeredBy: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => (data.macAddresses && data.macAddresses.length > 0) || (data.serialNumbers && data.serialNumbers.length > 0),
|
||||
{ message: "Informe ao menos um MAC address ou número de série" }
|
||||
)
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = registerSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const registration = await client.mutation(api.machines.register, {
|
||||
provisioningSecret: payload.provisioningSecret,
|
||||
tenantId: payload.tenantId ?? DEFAULT_TENANT_ID,
|
||||
companySlug: payload.companySlug ?? undefined,
|
||||
hostname: payload.hostname,
|
||||
os: payload.os,
|
||||
macAddresses: payload.macAddresses,
|
||||
serialNumbers: payload.serialNumbers,
|
||||
metadata: payload.metadata,
|
||||
registeredBy: payload.registeredBy,
|
||||
})
|
||||
|
||||
const account = await ensureMachineAccount({
|
||||
machineId: registration.machineId,
|
||||
tenantId: registration.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: payload.hostname,
|
||||
machineToken: registration.machineToken,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
machineId: registration.machineId as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
machineId: registration.machineId,
|
||||
tenantId: registration.tenantId,
|
||||
companyId: registration.companyId,
|
||||
companySlug: registration.companySlug,
|
||||
machineToken: registration.machineToken,
|
||||
machineEmail: account.authEmail,
|
||||
expiresAt: registration.expiresAt,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[machines.register] Falha no provisionamento", error)
|
||||
return NextResponse.json({ error: "Falha ao provisionar máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
96
src/app/api/machines/sessions/route.ts
Normal file
96
src/app/api/machines/sessions/route.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { ConvexHttpClient } from "convex/browser"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { env } from "@/lib/env"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { ensureMachineAccount } from "@/server/machines-auth"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
const sessionSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
rememberMe: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (request.method !== "POST") {
|
||||
return NextResponse.json({ error: "Método não permitido" }, { status: 405 })
|
||||
}
|
||||
|
||||
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
|
||||
if (!convexUrl) {
|
||||
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
const raw = await request.json()
|
||||
payload = sessionSchema.parse(raw)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl)
|
||||
|
||||
try {
|
||||
const resolved = await client.mutation(api.machines.resolveToken, { machineToken: payload.machineToken })
|
||||
let machineEmail = resolved.machine.authEmail ?? null
|
||||
|
||||
if (!machineEmail) {
|
||||
const account = await ensureMachineAccount({
|
||||
machineId: resolved.machine._id,
|
||||
tenantId: resolved.machine.tenantId ?? DEFAULT_TENANT_ID,
|
||||
hostname: resolved.machine.hostname,
|
||||
machineToken: payload.machineToken,
|
||||
})
|
||||
|
||||
await client.mutation(api.machines.linkAuthAccount, {
|
||||
machineId: resolved.machine._id as Id<"machines">,
|
||||
authUserId: account.authUserId,
|
||||
authEmail: account.authEmail,
|
||||
})
|
||||
|
||||
machineEmail = account.authEmail
|
||||
}
|
||||
|
||||
const signIn = await auth.api.signInEmail({
|
||||
body: {
|
||||
email: machineEmail,
|
||||
password: payload.machineToken,
|
||||
rememberMe: payload.rememberMe ?? true,
|
||||
},
|
||||
returnHeaders: true,
|
||||
})
|
||||
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
machine: {
|
||||
id: resolved.machine._id,
|
||||
hostname: resolved.machine.hostname,
|
||||
osName: resolved.machine.osName,
|
||||
osVersion: resolved.machine.osVersion,
|
||||
architecture: resolved.machine.architecture,
|
||||
status: resolved.machine.status,
|
||||
lastHeartbeatAt: resolved.machine.lastHeartbeatAt,
|
||||
companyId: resolved.machine.companyId,
|
||||
companySlug: resolved.machine.companySlug,
|
||||
metadata: resolved.machine.metadata,
|
||||
},
|
||||
session: signIn.response,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
|
||||
signIn.headers.forEach((value, key) => {
|
||||
response.headers.set(key, value)
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[machines.sessions] Falha ao criar sessão", error)
|
||||
return NextResponse.json({ error: "Falha ao autenticar máquina" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue