diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 8c22cc9..6768304 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,7 +6,7 @@ import { env } from "@minmon/env/server"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { Elysia } from "elysia"; -const app = new Elysia() +new Elysia() .use( cors({ origin: env.CORS_ORIGIN, diff --git a/packages/api/package.json b/packages/api/package.json index da20713..1223235 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,6 +17,8 @@ "@trpc/client": "catalog:", "@trpc/server": "catalog:", "dotenv": "catalog:", + "drizzle-orm": "^0.45.1", + "effect": "^3.18.4", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/api/src/lib/domain-check.ts b/packages/api/src/lib/domain-check.ts new file mode 100644 index 0000000..48c9fca --- /dev/null +++ b/packages/api/src/lib/domain-check.ts @@ -0,0 +1,91 @@ +import { createDb, domain } from "@minmon/db"; +import { eq } from "drizzle-orm"; +import { resolve4 } from "node:dns/promises"; +import { Effect } from "effect"; + +export type DomainCheckStatus = "MATCH" | "MISMATCH" | "UNRESOLVED"; + +export type DomainCheckResult = { + status: DomainCheckStatus; + resolvedIps: string[]; + checkedAt: Date; + message: string | null; +}; + +type DomainCheckTarget = { + name: string; + expectedServerIp: string; +}; + +export function resolveIpv4ARecords(hostname: string) { + return Effect.tryPromise({ + try: async () => [...new Set(await resolve4(hostname))], + catch: (error: unknown) => + error instanceof Error ? error : new Error("Unable to resolve IPv4 A records"), + }); +} + +export function checkDomainDns(target: DomainCheckTarget) { + return Effect.match(resolveIpv4ARecords(target.name), { + onSuccess: (resolvedIps: string[]): DomainCheckResult => ({ + status: resolvedIps.includes(target.expectedServerIp) ? "MATCH" : "MISMATCH", + resolvedIps, + checkedAt: new Date(), + message: null, + }), + onFailure: (error: Error): DomainCheckResult => ({ + status: "UNRESOLVED", + resolvedIps: [], + checkedAt: new Date(), + message: error.message, + }), + }); +} + +export async function persistDomainCheck( + database: ReturnType, + input: { + domainId: string; + result: DomainCheckResult; + }, +) { + const [updatedDomain] = await database + .update(domain) + .set({ + resolutionStatus: input.result.status, + lastResolvedIps: input.result.resolvedIps, + lastResolvedIp: input.result.resolvedIps[0] ?? null, + lastCheckedAt: input.result.checkedAt, + lastCheckMessage: input.result.message, + }) + .where(eq(domain.id, input.domainId)) + .returning(); + + return updatedDomain ?? null; +} + +export async function runAndPersistDomainCheck( + database: ReturnType, + input: { + domainId: string; + name: string; + expectedServerIp: string; + }, +) { + const result = await Effect.runPromise( + checkDomainDns({ + name: input.name, + expectedServerIp: input.expectedServerIp, + }), + ); + + const updatedDomain = await persistDomainCheck(database, { + domainId: input.domainId, + result, + }); + + return { + result, + domain: updatedDomain, + }; +} diff --git a/packages/api/src/routers/dashboard.ts b/packages/api/src/routers/dashboard.ts new file mode 100644 index 0000000..205972d --- /dev/null +++ b/packages/api/src/routers/dashboard.ts @@ -0,0 +1,75 @@ +import { db } from "@minmon/db"; +import { domain, note, server, service } from "@minmon/db"; +import { count, desc, eq, inArray } from "drizzle-orm"; + +import { protectedProcedure, router } from "../index"; + +export const dashboardRouter = router({ + summary: protectedProcedure.query(async () => { + const [ + serverCount, + serviceCount, + domainCount, + inactiveServices, + domainStatuses, + recentNotes, + servicesWithServer, + ] = await Promise.all([ + db.select({ count: count() }).from(server), + db.select({ count: count() }).from(service), + db.select({ count: count() }).from(domain), + db + .select({ count: count() }) + .from(service) + .where(inArray(service.status, ["down", "maintenance"])), + db + .select({ + status: domain.resolutionStatus, + }) + .from(domain), + db + .select({ + id: note.id, + title: note.title, + category: note.category, + updatedAt: note.updatedAt, + serverId: note.serverId, + }) + .from(note) + .orderBy(desc(note.updatedAt)) + .limit(5), + db + .select({ + id: service.id, + name: service.name, + status: service.status, + serverName: server.name, + }) + .from(service) + .innerJoin(server, eq(service.serverId, server.id)) + .where(inArray(service.status, ["down", "maintenance"])) + .orderBy(desc(service.updatedAt)) + .limit(5), + ]); + + const domainsWithMatchIp = domainStatuses.filter((item) => item.status === "MATCH").length; + const domainsWithMismatchIp = domainStatuses.filter((item) => item.status === "MISMATCH").length; + const domainsUnresolved = domainStatuses.filter((item) => item.status === "UNRESOLVED").length; + + return { + totals: { + servers: serverCount[0]?.count ?? 0, + services: serviceCount[0]?.count ?? 0, + domains: domainCount[0]?.count ?? 0, + }, + domainChecks: { + match: domainsWithMatchIp, + mismatch: domainsWithMismatchIp, + unresolved: domainsUnresolved, + }, + inactiveServices: inactiveServices[0]?.count ?? 0, + recentNotes, + recentServiceIssues: servicesWithServer, + }; + }), +}); diff --git a/packages/api/src/routers/domains.ts b/packages/api/src/routers/domains.ts new file mode 100644 index 0000000..9ba0b87 --- /dev/null +++ b/packages/api/src/routers/domains.ts @@ -0,0 +1,112 @@ +import { db } from "@minmon/db"; +import { domain, service } from "@minmon/db"; +import { desc, eq } from "drizzle-orm"; + +import { runAndPersistDomainCheck } from "../lib/domain-check"; +import { protectedProcedure, router } from "../index"; +import { + byIdSchema, + domainCreateSchema, + domainListSchema, + domainUpdateSchema, +} from "./schemas"; +import { + createEntityId, + requireDomain, + requireService, + requireServiceWithServer, + stripUndefined, +} from "./utils"; + +export const domainsRouter = router({ + list: protectedProcedure.input(domainListSchema).query(async ({ input }) => { + if (!input?.serviceId) { + return db.select().from(domain).orderBy(desc(domain.updatedAt)); + } + + await requireService(input.serviceId); + + return db + .select() + .from(domain) + .where(eq(domain.serviceId, input.serviceId)) + .orderBy(desc(domain.updatedAt)); + }), + + byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { + return requireDomain(input.id); + }), + + create: protectedProcedure.input(domainCreateSchema).mutation(async ({ input }) => { + const { service: parentService, server: parentServer } = await requireServiceWithServer(input.serviceId); + + const expectedServerIp = input.expectedServerIp ?? parentServer.primaryIpAddress; + + const [created] = await db + .insert(domain) + .values({ + id: createEntityId(), + serviceId: parentService.id, + name: input.name, + expectedServerIp, + remarks: input.remarks, + }) + .returning(); + + return created; + }), + + update: protectedProcedure.input(domainUpdateSchema).mutation(async ({ input }) => { + const { id, serviceId, ...changes } = input; + const existing = await requireDomain(id); + + let nextExpectedServerIp = changes.expectedServerIp; + + if (serviceId && serviceId !== existing.serviceId) { + const { server: parentServer } = await requireServiceWithServer(serviceId); + if (!nextExpectedServerIp) { + nextExpectedServerIp = parentServer.primaryIpAddress; + } + } + + const [updated] = await db + .update(domain) + .set(stripUndefined({ serviceId, expectedServerIp: nextExpectedServerIp, ...changes })) + .where(eq(domain.id, id)) + .returning(); + + return updated; + }), + + delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => { + await requireDomain(input.id); + await db.delete(domain).where(eq(domain.id, input.id)); + return { id: input.id }; + }), + + check: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => { + const existing = await requireDomain(input.id); + + const result = await runAndPersistDomainCheck(db, { + domainId: existing.id, + name: existing.name, + expectedServerIp: existing.expectedServerIp, + }); + + return { + domain: result.domain ?? existing, + check: result.result, + }; + }), + + serviceOptions: protectedProcedure.query(async () => { + return db + .select({ + id: service.id, + name: service.name, + serverId: service.serverId, + }) + .from(service) + .orderBy(desc(service.updatedAt)); + }), +}); diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index 1de652b..c287721 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -1,14 +1,19 @@ -import { protectedProcedure, publicProcedure, router } from "../index"; +import { publicProcedure, router } from "../index"; + +import { dashboardRouter } from "./dashboard"; +import { domainsRouter } from "./domains"; +import { notesRouter } from "./notes"; +import { serversRouter } from "./servers"; +import { servicesRouter } from "./services"; export const appRouter = router({ healthCheck: publicProcedure.query(() => { return "OK"; }), - privateData: protectedProcedure.query(({ ctx }) => { - return { - message: "This is private", - user: ctx.session.user, - }; - }), + dashboard: dashboardRouter, + servers: serversRouter, + services: servicesRouter, + domains: domainsRouter, + notes: notesRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/notes.ts b/packages/api/src/routers/notes.ts new file mode 100644 index 0000000..150df17 --- /dev/null +++ b/packages/api/src/routers/notes.ts @@ -0,0 +1,79 @@ +import { db } from "@minmon/db"; +import { note, server } from "@minmon/db"; +import { desc, eq } from "drizzle-orm"; + +import { protectedProcedure, router } from "../index"; +import { byIdSchema, noteCreateSchema, noteListSchema, noteUpdateSchema } from "./schemas"; +import { + createEntityId, + requireNote, + requireServer, + stripUndefined, +} from "./utils"; + +export const notesRouter = router({ + list: protectedProcedure.input(noteListSchema).query(async ({ input }) => { + if (!input?.serverId) { + return db.select().from(note).orderBy(desc(note.updatedAt)); + } + + await requireServer(input.serverId); + + return db + .select() + .from(note) + .where(eq(note.serverId, input.serverId)) + .orderBy(desc(note.updatedAt)); + }), + + byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { + return requireNote(input.id); + }), + + create: protectedProcedure.input(noteCreateSchema).mutation(async ({ input }) => { + await requireServer(input.serverId); + + const [created] = await db + .insert(note) + .values({ + id: createEntityId(), + ...input, + }) + .returning(); + + return created; + }), + + update: protectedProcedure.input(noteUpdateSchema).mutation(async ({ input }) => { + const { id, serverId, ...changes } = input; + await requireNote(id); + + if (serverId) { + await requireServer(serverId); + } + + const [updated] = await db + .update(note) + .set(stripUndefined({ serverId, ...changes })) + .where(eq(note.id, id)) + .returning(); + + return updated; + }), + + delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => { + await requireNote(input.id); + await db.delete(note).where(eq(note.id, input.id)); + return { id: input.id }; + }), + + serverOptions: protectedProcedure.query(async () => { + return db + .select({ + id: server.id, + name: server.name, + }) + .from(server) + .orderBy(desc(server.updatedAt)); + }), +}); diff --git a/packages/api/src/routers/schemas.ts b/packages/api/src/routers/schemas.ts new file mode 100644 index 0000000..a7256e8 --- /dev/null +++ b/packages/api/src/routers/schemas.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +export const entityIdSchema = z.string().min(1).max(191); + +export const ipv4Schema = z + .string() + .trim() + .regex( + /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/, + "Must be a valid IPv4 address", + ); + +export const optionalTextSchema = z.string().trim().max(5000).nullable().optional(); + +export const hostnameSchema = z + .string() + .trim() + .min(1) + .max(253) + .regex( + /^(?=.{1,253}$)(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/, + "Must be a valid domain or subdomain", + ); + +export const serverStatusSchema = z.enum(["active", "maintenance", "inactive"]); +export const serviceTypeSchema = z.enum(["web_app", "mail", "database", "api", "proxy", "other"]); +export const serviceProtocolSchema = z.enum(["http", "https", "tcp", "udp", "other"]); +export const serviceStatusSchema = z.enum(["active", "down", "maintenance"]); +export const domainCheckStatusSchema = z.enum(["MATCH", "MISMATCH", "UNRESOLVED"]); + +export const byIdSchema = z.object({ + id: entityIdSchema, +}); + +export const serverCreateSchema = z.object({ + name: z.string().trim().min(1).max(120), + primaryIpAddress: ipv4Schema, + secondaryIpAddress: ipv4Schema.nullable().optional(), + operatingSystem: z.string().trim().max(120).nullable().optional(), + location: z.string().trim().max(120).nullable().optional(), + provider: z.string().trim().max(120).nullable().optional(), + description: optionalTextSchema, + status: serverStatusSchema.default("active"), +}); + +export const serverUpdateSchema = serverCreateSchema.partial().extend({ + id: entityIdSchema, +}); + +export const serviceListSchema = z + .object({ + serverId: entityIdSchema.optional(), + }) + .optional(); + +export const serviceCreateSchema = z.object({ + serverId: entityIdSchema, + name: z.string().trim().min(1).max(120), + type: serviceTypeSchema.default("other"), + internalPort: z.coerce.number().int().min(1).max(65535).nullable().optional(), + externalPort: z.coerce.number().int().min(1).max(65535).nullable().optional(), + protocol: serviceProtocolSchema.default("http"), + status: serviceStatusSchema.default("active"), + description: optionalTextSchema, +}); + +export const serviceUpdateSchema = serviceCreateSchema.partial().extend({ + id: entityIdSchema, +}); + +export const domainListSchema = z + .object({ + serviceId: entityIdSchema.optional(), + }) + .optional(); + +export const domainCreateSchema = z.object({ + serviceId: entityIdSchema, + name: hostnameSchema, + expectedServerIp: ipv4Schema.optional(), + remarks: optionalTextSchema, +}); + +export const domainUpdateSchema = z.object({ + id: entityIdSchema, + serviceId: entityIdSchema.optional(), + name: hostnameSchema.optional(), + expectedServerIp: ipv4Schema.optional(), + remarks: optionalTextSchema, +}); + +export const noteListSchema = z + .object({ + serverId: entityIdSchema.optional(), + }) + .optional(); + +export const noteCreateSchema = z.object({ + serverId: entityIdSchema, + title: z.string().trim().min(1).max(160), + content: z.string().trim().min(1).max(10000), + category: z.string().trim().max(80).nullable().optional(), +}); + +export const noteUpdateSchema = noteCreateSchema.partial().extend({ + id: entityIdSchema, +}); diff --git a/packages/api/src/routers/servers.ts b/packages/api/src/routers/servers.ts new file mode 100644 index 0000000..b733d61 --- /dev/null +++ b/packages/api/src/routers/servers.ts @@ -0,0 +1,61 @@ +import { db } from "@minmon/db"; +import { note, server, service } from "@minmon/db"; +import { count, desc, eq } from "drizzle-orm"; + +import { protectedProcedure, router } from "../index"; +import { byIdSchema, serverCreateSchema, serverUpdateSchema } from "./schemas"; +import { createEntityId, requireServer, stripUndefined } from "./utils"; + +export const serversRouter = router({ + list: protectedProcedure.query(async () => { + return db.select().from(server).orderBy(desc(server.updatedAt)); + }), + + byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { + const item = await requireServer(input.id); + + const [serviceCount, noteCount] = await Promise.all([ + db.select({ count: count() }).from(service).where(eq(service.serverId, input.id)), + db.select({ count: count() }).from(note).where(eq(note.serverId, input.id)), + ]); + + return { + ...item, + summary: { + services: serviceCount[0]?.count ?? 0, + notes: noteCount[0]?.count ?? 0, + }, + }; + }), + + create: protectedProcedure.input(serverCreateSchema).mutation(async ({ input }) => { + const [created] = await db + .insert(server) + .values({ + id: createEntityId(), + ...input, + }) + .returning(); + + return created; + }), + + update: protectedProcedure.input(serverUpdateSchema).mutation(async ({ input }) => { + const { id, ...changes } = input; + await requireServer(id); + + const [updated] = await db + .update(server) + .set(stripUndefined(changes)) + .where(eq(server.id, id)) + .returning(); + + return updated; + }), + + delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => { + await requireServer(input.id); + await db.delete(server).where(eq(server.id, input.id)); + return { id: input.id }; + }), +}); diff --git a/packages/api/src/routers/services.ts b/packages/api/src/routers/services.ts new file mode 100644 index 0000000..8a45f93 --- /dev/null +++ b/packages/api/src/routers/services.ts @@ -0,0 +1,82 @@ +import { db } from "@minmon/db"; +import { domain, service } from "@minmon/db"; +import { count, desc, eq } from "drizzle-orm"; + +import { protectedProcedure, router } from "../index"; +import { + byIdSchema, + serviceCreateSchema, + serviceListSchema, + serviceUpdateSchema, +} from "./schemas"; +import { + createEntityId, + requireServer, + requireService, + stripUndefined, +} from "./utils"; + +export const servicesRouter = router({ + list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => { + if (!input?.serverId) { + return db.select().from(service).orderBy(desc(service.updatedAt)); + } + + await requireServer(input.serverId); + + return db + .select() + .from(service) + .where(eq(service.serverId, input.serverId)) + .orderBy(desc(service.updatedAt)); + }), + + byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => { + const item = await requireService(input.id); + const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id)); + + return { + ...item, + summary: { + domains: domainCount?.count ?? 0, + }, + }; + }), + + create: protectedProcedure.input(serviceCreateSchema).mutation(async ({ input }) => { + await requireServer(input.serverId); + + const [created] = await db + .insert(service) + .values({ + id: createEntityId(), + ...input, + }) + .returning(); + + return created; + }), + + update: protectedProcedure.input(serviceUpdateSchema).mutation(async ({ input }) => { + const { id, serverId, ...changes } = input; + await requireService(id); + + if (serverId) { + await requireServer(serverId); + } + + const [updated] = await db + .update(service) + .set(stripUndefined({ serverId, ...changes })) + .where(eq(service.id, id)) + .returning(); + + return updated; + }), + + delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => { + await requireService(input.id); + await db.delete(service).where(eq(service.id, input.id)); + return { id: input.id }; + }), +}); diff --git a/packages/api/src/routers/utils.ts b/packages/api/src/routers/utils.ts new file mode 100644 index 0000000..a3fb63b --- /dev/null +++ b/packages/api/src/routers/utils.ts @@ -0,0 +1,87 @@ +import { TRPCError } from "@trpc/server"; +import { db } from "@minmon/db"; +import { domain, note, server, service } from "@minmon/db"; +import { and, eq } from "drizzle-orm"; + +export function createEntityId() { + return crypto.randomUUID(); +} + +export function stripUndefined>(values: T) { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => value !== undefined), + ) as Partial; +} + +export function notFound(message: string): never { + throw new TRPCError({ + code: "NOT_FOUND", + message, + }); +} + +export function badRequest(message: string): never { + throw new TRPCError({ + code: "BAD_REQUEST", + message, + }); +} + +export async function requireServer(serverId: string) { + const [item] = await db.select().from(server).where(eq(server.id, serverId)).limit(1); + if (!item) { + notFound("Server not found"); + } + return item; +} + +export async function requireService(serviceId: string) { + const [item] = await db.select().from(service).where(eq(service.id, serviceId)).limit(1); + if (!item) { + notFound("Service not found"); + } + return item; +} + +export async function requireDomain(domainId: string) { + const [item] = await db.select().from(domain).where(eq(domain.id, domainId)).limit(1); + if (!item) { + notFound("Domain not found"); + } + return item; +} + +export async function requireNote(noteId: string) { + const [item] = await db.select().from(note).where(eq(note.id, noteId)).limit(1); + if (!item) { + notFound("Note not found"); + } + return item; +} + +export async function requireServiceWithServer(serviceId: string) { + const item = await requireService(serviceId); + const parentServer = await requireServer(item.serverId); + return { service: item, server: parentServer }; +} + +export async function requireDomainWithRelations(domainId: string) { + const item = await requireDomain(domainId); + const parentService = await requireService(item.serviceId); + const parentServer = await requireServer(parentService.serverId); + return { domain: item, service: parentService, server: parentServer }; +} + +export async function ensureServiceBelongsToServer(serviceId: string, serverId: string) { + const [item] = await db + .select() + .from(service) + .where(and(eq(service.id, serviceId), eq(service.serverId, serverId))) + .limit(1); + + if (!item) { + badRequest("Service does not belong to the selected server"); + } + + return item; +}