Scaffold Homelab Manager foundation
Monorepo (Express+TS+Drizzle/libSQL server, React+Vite+Tabler web) matching the stack used by ScheduleTaskManager and Sloth Manager. Includes Authentik OIDC login with local admin/operator/viewer roles (first user becomes admin), a generalized audit log, encrypted-at-rest storage for future integration API tokens, the DB schema for all planned modules, and the Tabler-styled app shell/nav. Also ports the Secrets (expiry tracker) and IP Addresses (IPAM) modules from Sloth Manager onto the new stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
6bd2ed52c1
52 files changed
+8103
No files matched your search
@@ -0,0 +1,108 @@
|
||||
import { Router } from "express";
|
||||
import { isIP } from "node:net";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { ipamEntries } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const ipamRouter = Router();
|
||||
|
||||
ipamRouter.use(requireAuth);
|
||||
|
||||
ipamRouter.get("/", async (_req, res) => {
|
||||
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
|
||||
// matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference.
|
||||
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) });
|
||||
});
|
||||
|
||||
const createInput = z.object({
|
||||
ipAddress: z.string().refine((v) => isIP(v) !== 0, "Must be a valid IPv4 or IPv6 address"),
|
||||
label: z.string().max(200).optional(),
|
||||
vendor: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
notes: z.string().max(4000).optional(),
|
||||
});
|
||||
|
||||
const updateInput = createInput.omit({ ipAddress: true }).partial();
|
||||
|
||||
ipamRouter.post("/", requireRole("operator"), async (req, res) => {
|
||||
const parsed = createInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: ipamEntries.id })
|
||||
.from(ipamEntries)
|
||||
.where(eq(ipamEntries.ipAddress, parsed.data.ipAddress))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "duplicate_ip" });
|
||||
}
|
||||
|
||||
const [created] = await db.insert(ipamEntries).values(parsed.data).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "create",
|
||||
targetType: "ipam_entry",
|
||||
targetId: created.id,
|
||||
detail: { ipAddress: created.ipAddress },
|
||||
});
|
||||
|
||||
res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } });
|
||||
});
|
||||
|
||||
ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = updateInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(ipamEntries)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ipamEntries.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "update",
|
||||
targetType: "ipam_entry",
|
||||
targetId: id,
|
||||
detail: { ipAddress: updated.ipAddress },
|
||||
});
|
||||
|
||||
res.json({ entry: { ...updated, matchingDnsRecords: [] } });
|
||||
});
|
||||
|
||||
ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
await db.delete(ipamEntries).where(eq(ipamEntries.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "delete",
|
||||
targetType: "ipam_entry",
|
||||
targetId: id,
|
||||
detail: { ipAddress: existing.ipAddress },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
Reference in new issue
Block a user