api: add protected CRUD routers and DNS check logic

This commit is contained in:
Syahdan 2026-04-29 18:09:59 +07:00
parent c7edd90a7e
commit 5264cccb6c
11 changed files with 709 additions and 8 deletions

View file

@ -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<typeof createDb>,
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<typeof createDb>,
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,
};
}

View file

@ -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,
};
}),
});

View file

@ -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));
}),
});

View file

@ -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;

View file

@ -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));
}),
});

View file

@ -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,
});

View file

@ -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 };
}),
});

View file

@ -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 };
}),
});

View file

@ -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<T extends Record<string, unknown>>(values: T) {
return Object.fromEntries(
Object.entries(values).filter(([, value]) => value !== undefined),
) as Partial<T>;
}
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;
}