Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,6 @@ const nextConfig: NextConfig = {
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
// Self-hosted, fully self-contained app. Allow inline styles/scripts
// (Next injects some) but block framing and third-party origins.
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"img-src 'self' data: blob:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"connect-src 'self'",
"font-src 'self' data:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
],
},
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
CREATE TABLE "RateLimitBucket" (
"key" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"windowStartedAt" TIMESTAMP(3) NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RateLimitBucket_pkey" PRIMARY KEY ("key")
);

CREATE INDEX "RateLimitBucket_expiresAt_idx" ON "RateLimitBucket"("expiresAt");

CREATE TABLE "WorkflowWebhook" (
"token" TEXT NOT NULL,
"workflowId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkflowWebhook_pkey" PRIMARY KEY ("token")
);

CREATE UNIQUE INDEX "WorkflowWebhook_workflowId_nodeId_key"
ON "WorkflowWebhook"("workflowId", "nodeId");
CREATE INDEX "WorkflowWebhook_workflowId_idx"
ON "WorkflowWebhook"("workflowId");
ALTER TABLE "WorkflowWebhook" ADD CONSTRAINT "WorkflowWebhook_workflowId_fkey"
FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- Backfill existing webhook nodes. Tokens are already stored in workflow.graph;
-- the materialized table only makes lookup indexed and bounded. Legacy graphs
-- may contain duplicate tokens/node IDs, so preserve one deterministic row
-- instead of making the migration fail on previously tolerated ambiguous data.
INSERT INTO "WorkflowWebhook" ("token", "workflowId", "nodeId")
SELECT DISTINCT ON (node->'config'->>'token')
node->'config'->>'token',
workflow."id",
node->>'id'
FROM "Workflow" AS workflow
CROSS JOIN LATERAL jsonb_array_elements(
CASE
WHEN jsonb_typeof(workflow."graph"->'nodes') = 'array' THEN workflow."graph"->'nodes'
ELSE '[]'::jsonb
END
) AS node
WHERE node->>'kind' = 'trigger.webhook'
AND COALESCE(node->>'id', '') <> ''
AND COALESCE(btrim(node->'config'->>'token'), '') <> ''
ORDER BY node->'config'->>'token', workflow."createdAt", workflow."id", node->>'id'
ON CONFLICT DO NOTHING;

-- Integration identity resolution compares the first DNS label case-insensitively.
-- Functional partial indexes avoid loading/scanning complete active inventories.
CREATE INDEX "Device_active_normalized_name_idx"
ON "Device" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1))))
WHERE "status" <> 'REMOVED';
CREATE INDEX "VirtualMachine_active_normalized_name_idx"
ON "VirtualMachine" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1))))
WHERE "status" <> 'REMOVED';
CREATE INDEX "Container_active_normalized_name_idx"
ON "Container" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1))))
WHERE "status" <> 'REMOVED';
28 changes: 27 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ model AppSetting {
updatedAt DateTime @updatedAt
}

/// Shared fixed-window counters used for login and public webhook throttling.
/// Rows expire naturally and may be pruned by maintenance without affecting data.
model RateLimitBucket {
key String @id
count Int
windowStartedAt DateTime
expiresAt DateTime
updatedAt DateTime @updatedAt

@@index([expiresAt])
}

// ---------- integrations ----------

model IntegrationConfig {
Expand Down Expand Up @@ -1208,11 +1220,25 @@ model Workflow {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

runs WorkflowRun[]
runs WorkflowRun[]
webhooks WorkflowWebhook[]

@@index([name])
}

/// Materialized lookup for public webhook tokens. The source of truth remains
/// the workflow graph; this table is rebuilt transactionally whenever a graph changes.
model WorkflowWebhook {
token String @id
workflowId String
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
nodeId String
createdAt DateTime @default(now())

@@unique([workflowId, nodeId])
@@index([workflowId])
}

/// One execution of a workflow. Step outputs are persisted with secret output
/// keys redacted; secret values only exist in the one-time run response.
model WorkflowRun {
Expand Down
9 changes: 2 additions & 7 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { LockKeyhole } from "lucide-react";
import { requirePageUser } from "@/lib/auth/guards";
import { isLockedDemoMode } from "@/lib/demo/mode";
import { isMobileView } from "@/lib/device";
Expand All @@ -10,6 +9,7 @@ import { PrivacyProvider } from "@/components/privacy/privacy-provider";
import { SidebarNav } from "@/components/shell/sidebar";
import { Topbar } from "@/components/shell/topbar";
import { MobileShell } from "@/components/mobile/shell/mobile-shell";
import { DemoModeBanner } from "@/components/shell/demo-mode-banner";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -70,12 +70,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
</aside>
<div className="flex min-w-0 flex-1 flex-col md:pl-60">
<Topbar instanceName={shellIdentity.instanceName} user={shellUser} />
{demoLocked && (
<div className="flex items-center justify-center gap-2 border-b border-violet-500/20 bg-violet-500/10 px-4 py-2 text-center text-xs font-medium text-violet-700 dark:text-violet-300">
<LockKeyhole className="size-3.5" /> Public demo — exploration and
mock AI are enabled; persistent changes are locked.
</div>
)}
{demoLocked && <DemoModeBanner />}
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
{aiConfig.enabled && <ChatDock />}
Expand Down
58 changes: 57 additions & 1 deletion src/app/(dashboard)/network/access-map/access-map-data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "server-only";

import { prisma } from "@/lib/db";
import { assertDatasetBudget } from "@/lib/dataset-budget";
import { anonymizeForDisplay } from "@/lib/privacy/server";
import { deriveAccessGraph } from "@/lib/topology/access";
import { resolveObservedAssetAddresses } from "@/lib/topology/address-evidence";
Expand All @@ -22,13 +23,31 @@ import {
buildTailscaleTailnets,
} from "./access-map-providers";

const ACCESS_MAP_BUDGET = {
networks: 2_000,
firewallRules: 25_000,
aliases: 10_000,
integrationTypes: 100,
portForwards: 25_000,
addresses: 50_000,
guestInterfaces: 50_000,
leases: 50_000,
neighbors: 50_000,
wirelessNetworks: 10_000,
wirelessAps: 10_000,
switchConfigs: 2_000,
switchVlansPerConfig: 4_096,
switchPortsPerConfig: 2_000,
} as const;

/** Load, assemble, and anonymize the complete access-map render model. */
export async function loadAccessMapData() {
const [networkRows, rules, aliases, pveIpsets, activeIntegrations, cloudflareSnapshots, tailscaleSnapshots, edgePortForwards] = await Promise.all([
prisma.network.findMany({
where: { status: { not: "REMOVED" } },
orderBy: { name: "asc" },
select: { id: true, name: true, vlanId: true, cidr: true, gateway: true, externalId: true, purpose: true, source: true },
take: ACCESS_MAP_BUDGET.networks + 1,
}),
prisma.firewallRule.findMany({
// Gateway-level rules only — Proxmox guest-isolation rules feed the
Expand All @@ -49,19 +68,23 @@ export async function loadAccessMapData() {
metadata: true,
source: true,
},
take: ACCESS_MAP_BUDGET.firewallRules + 1,
}),
prisma.firewallAlias.findMany({
where: { status: { not: "REMOVED" }, aliasType: { notIn: ["pve-ipset", "pve-alias"] } },
select: { name: true, aliasType: true, content: true },
take: ACCESS_MAP_BUDGET.aliases + 1,
}),
prisma.firewallAlias.findMany({
where: { status: { not: "REMOVED" }, aliasType: { in: ["pve-ipset", "pve-alias"] } },
select: { name: true, content: true },
take: ACCESS_MAP_BUDGET.aliases + 1,
}),
prisma.integrationConfig.findMany({
where: { enabled: true },
select: { type: true },
distinct: ["type"],
take: ACCESS_MAP_BUDGET.integrationTypes + 1,
}),
listStoredCloudflareSnapshots(),
listStoredTailscaleSnapshots(),
Expand All @@ -79,9 +102,17 @@ export async function loadAccessMapData() {
descriptionText: true,
source: true,
},
take: ACCESS_MAP_BUDGET.portForwards + 1,
}),
]);

assertDatasetBudget("Access-map networks", networkRows, ACCESS_MAP_BUDGET.networks);
assertDatasetBudget("Access-map firewall rules", rules, ACCESS_MAP_BUDGET.firewallRules);
assertDatasetBudget("Access-map firewall aliases", aliases, ACCESS_MAP_BUDGET.aliases);
assertDatasetBudget("Access-map Proxmox address sets", pveIpsets, ACCESS_MAP_BUDGET.aliases);
assertDatasetBudget("Access-map integration types", activeIntegrations, ACCESS_MAP_BUDGET.integrationTypes);
assertDatasetBudget("Access-map edge port forwards", edgePortForwards, ACCESS_MAP_BUDGET.portForwards);

const pveAddressSets = pveIpsets.map((set) => ({ name: set.name, entries: set.content }));
const integrationEvidence = buildIntegrationEvidence(
activeIntegrations.map(({ type }) => type),
Expand Down Expand Up @@ -120,6 +151,7 @@ export async function loadAccessMapData() {
},
},
},
take: ACCESS_MAP_BUDGET.addresses + 1,
}),
prisma.networkInterface.findMany({
where: {
Expand All @@ -132,17 +164,25 @@ export async function loadAccessMapData() {
vm: { select: { id: true, externalId: true, name: true, metadata: true } },
container: { select: { id: true, externalId: true, name: true, metadata: true } },
},
take: ACCESS_MAP_BUDGET.guestInterfaces + 1,
}),
prisma.dhcpLease.findMany({
where: { status: { not: "REMOVED" } },
select: { id: true, ipAddress: true, macAddress: true, hostname: true, isStatic: true, networkId: true },
take: ACCESS_MAP_BUDGET.leases + 1,
}),
prisma.networkNeighbor.findMany({
where: { status: { not: "REMOVED" }, permanent: false },
select: { id: true, ipAddress: true, macAddress: true, hostname: true, manufacturer: true, networkId: true },
take: ACCESS_MAP_BUDGET.neighbors + 1,
}),
]);

assertDatasetBudget("Access-map IP addresses", ips, ACCESS_MAP_BUDGET.addresses);
assertDatasetBudget("Access-map guest interfaces", guestInterfaces, ACCESS_MAP_BUDGET.guestInterfaces);
assertDatasetBudget("Access-map DHCP leases", leases, ACCESS_MAP_BUDGET.leases);
assertDatasetBudget("Access-map network neighbors", neighbors, ACCESS_MAP_BUDGET.neighbors);

const resolvedGuestObservations = resolveObservedAssetAddresses(
guestInterfaces.flatMap((iface) => {
const ownerId = iface.vm?.id ?? iface.container?.id;
Expand Down Expand Up @@ -194,20 +234,28 @@ export async function loadAccessMapData() {
vlanId: true,
networkId: true,
},
take: ACCESS_MAP_BUDGET.wirelessNetworks + 1,
}),
prisma.wirelessAp.findMany({
where: { status: { not: "REMOVED" } },
orderBy: { name: "asc" },
select: { id: true, name: true, model: true },
take: ACCESS_MAP_BUDGET.wirelessAps + 1,
}),
]);
assertDatasetBudget("Access-map wireless networks", wifiSsids, ACCESS_MAP_BUDGET.wirelessNetworks);
assertDatasetBudget("Access-map wireless access points", wifiAps, ACCESS_MAP_BUDGET.wirelessAps);

const switchConfigs = await prisma.switchConfig.findMany({
include: {
device: { select: { id: true, name: true } },
vlans: { select: { vlanId: true, svIpAddress: true, networkId: true } },
vlans: {
take: ACCESS_MAP_BUDGET.switchVlansPerConfig + 1,
select: { vlanId: true, svIpAddress: true, networkId: true },
},
ports: {
orderBy: { sortOrder: "asc" },
take: ACCESS_MAP_BUDGET.switchPortsPerConfig + 1,
select: {
shortName: true,
description: true,
Expand All @@ -223,7 +271,13 @@ export async function loadAccessMapData() {
},
},
},
take: ACCESS_MAP_BUDGET.switchConfigs + 1,
});
assertDatasetBudget("Access-map switch configurations", switchConfigs, ACCESS_MAP_BUDGET.switchConfigs);
for (const config of switchConfigs) {
assertDatasetBudget("VLANs in one access-map switch", config.vlans, ACCESS_MAP_BUDGET.switchVlansPerConfig);
assertDatasetBudget("Ports in one access-map switch", config.ports, ACCESS_MAP_BUDGET.switchPortsPerConfig);
}

const edgeIngressRules = edgePortForwards.map((forward) => ({
id: `edge-nat:${forward.id}`,
Expand Down Expand Up @@ -395,7 +449,9 @@ export async function loadAccessMapData() {
enabled: true,
metadata: true,
},
take: ACCESS_MAP_BUDGET.firewallRules + 1,
});
assertDatasetBudget("Access-map Proxmox firewall rules", pveRules, ACCESS_MAP_BUDGET.firewallRules);

const { pve, homeNetworkId, groupRuleCount } = buildAccessMapPveData(
guestInterfaces,
Expand Down
44 changes: 42 additions & 2 deletions src/app/(dashboard)/network/access-map/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import Link from "next/link";
import { Waypoints } from "lucide-react";
import { requirePageUser } from "@/lib/auth/guards";
import { isMobileView } from "@/lib/device";
import { DatasetBudgetExceededError } from "@/lib/dataset-budget";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { NetworkAccessMap } from "@/components/topology/network-access-map";
import { MobileAccessMap } from "@/components/mobile/pages/maps/mobile-access-map";
import { MobilePageHeader } from "@/components/mobile/ui/mobile-page-header";
import { MobilePage } from "@/components/mobile/ui/mobile-page";
import { MobileEmpty } from "@/components/mobile/ui/mobile-list";
import { loadAccessMapData } from "./access-map-data";

export const dynamic = "force-dynamic";
Expand All @@ -16,16 +20,52 @@ export const metadata = { title: "Access map" };

export default async function AccessMapPage() {
const { user } = await requirePageUser();
const mobile = await isMobileView();

let data: Awaited<ReturnType<typeof loadAccessMapData>>;
try {
data = await loadAccessMapData();
} catch (err) {
if (!(err instanceof DatasetBudgetExceededError)) throw err;
const description = `${err.dataset} exceeds the safe processing budget. Archive stale inventory records, then reload this view.`;
if (mobile) {
return (
<>
<MobilePageHeader title="Access map" />
<MobilePage>
<MobileEmpty
icon={<Waypoints />}
title="Access map paused"
description={description}
/>
</MobilePage>
</>
);
}
return (
<div>
<PageHeader
title="Access map"
description="One reachability view assembled from every connected source: gateway policy, Proxmox workload firewalls, observed addresses, switching, and WiFi."
/>
<EmptyState
icon={Waypoints}
title="Access map paused"
description={description}
/>
</div>
);
}

const {
display,
integrationEvidence,
homeNetworkId,
hasSwitchConfigs,
empty,
} = await loadAccessMapData();
} = data;

if (await isMobileView()) {
if (mobile) {
return (
<MobileAccessMap
graph={display.graph}
Expand Down
Loading