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
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -18,6 +18,7 @@ import type * as crons from "../crons.js";
|
|||
import type * as fields from "../fields.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as machines from "../machines.js";
|
||||
import type * as migrations from "../migrations.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
|
|
@ -53,6 +54,7 @@ declare const fullApi: ApiFromModules<{
|
|||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
invites: typeof invites;
|
||||
machines: typeof machines;
|
||||
migrations: typeof migrations;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
|
|
|
|||
504
convex/machines.ts
Normal file
504
convex/machines.ts
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
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<string, unknown>) {
|
||||
if (!current || typeof current !== "object") return patch
|
||||
return { ...(current as Record<string, unknown>), ...patch }
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
|
||||
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<string, unknown> | null = null
|
||||
let inventory: Record<string, unknown> | null = null
|
||||
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const metaRecord = metadata as Record<string, unknown>
|
||||
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
|
||||
metrics = metaRecord.metrics as Record<string, unknown>
|
||||
}
|
||||
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
|
||||
inventory = metaRecord.inventory as Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
|
@ -238,4 +238,43 @@ export default defineSchema({
|
|||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_token", ["tenantId", "token"])
|
||||
.index("by_invite", ["tenantId", "inviteId"]),
|
||||
|
||||
machines: defineTable({
|
||||
tenantId: v.string(),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
companySlug: v.optional(v.string()),
|
||||
authUserId: v.optional(v.string()),
|
||||
authEmail: v.optional(v.string()),
|
||||
hostname: v.string(),
|
||||
osName: v.string(),
|
||||
osVersion: v.optional(v.string()),
|
||||
architecture: v.optional(v.string()),
|
||||
macAddresses: v.array(v.string()),
|
||||
serialNumbers: v.array(v.string()),
|
||||
fingerprint: v.string(),
|
||||
metadata: v.optional(v.any()),
|
||||
lastHeartbeatAt: v.optional(v.number()),
|
||||
status: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
registeredBy: v.optional(v.string()),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_company", ["tenantId", "companyId"])
|
||||
.index("by_tenant_fingerprint", ["tenantId", "fingerprint"]),
|
||||
|
||||
machineTokens: defineTable({
|
||||
tenantId: v.string(),
|
||||
machineId: v.id("machines"),
|
||||
tokenHash: v.string(),
|
||||
expiresAt: v.number(),
|
||||
revoked: v.boolean(),
|
||||
createdAt: v.number(),
|
||||
lastUsedAt: v.optional(v.number()),
|
||||
usageCount: v.optional(v.number()),
|
||||
type: v.optional(v.string()),
|
||||
})
|
||||
.index("by_token_hash", ["tokenHash"])
|
||||
.index("by_machine", ["machineId"])
|
||||
.index("by_tenant_machine", ["tenantId", "machineId"]),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue