mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-13 13:26:36 +00:00
Logging and http working
This commit is contained in:
@@ -1004,6 +1004,7 @@ export const requestAuditLog = pgTable(
|
|||||||
actor: text("actor"),
|
actor: text("actor"),
|
||||||
actorId: text("actorId"),
|
actorId: text("actorId"),
|
||||||
resourceId: integer("resourceId"),
|
resourceId: integer("resourceId"),
|
||||||
|
siteResourceId: integer("siteResourceId"),
|
||||||
ip: text("ip"),
|
ip: text("ip"),
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
userAgent: text("userAgent"),
|
userAgent: text("userAgent"),
|
||||||
|
|||||||
@@ -1104,6 +1104,7 @@ export const requestAuditLog = sqliteTable(
|
|||||||
actor: text("actor"),
|
actor: text("actor"),
|
||||||
actorId: text("actorId"),
|
actorId: text("actorId"),
|
||||||
resourceId: integer("resourceId"),
|
resourceId: integer("resourceId"),
|
||||||
|
siteResourceId: integer("siteResourceId"),
|
||||||
ip: text("ip"),
|
ip: text("ip"),
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
userAgent: text("userAgent"),
|
userAgent: text("userAgent"),
|
||||||
|
|||||||
@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the org for this site
|
// Look up the org for this site and check retention settings
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
|
.select({
|
||||||
|
orgId: sites.orgId,
|
||||||
|
orgSubnet: orgs.subnet,
|
||||||
|
settingsLogRetentionDaysConnection:
|
||||||
|
orgs.settingsLogRetentionDaysConnection
|
||||||
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
.where(eq(sites.siteId, newt.siteId));
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const orgId = site.orgId;
|
const orgId = site.orgId;
|
||||||
|
|
||||||
|
if (site.settingsLogRetentionDaysConnection === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Connection log retention is disabled for org ${orgId}, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
||||||
// reconstruct the exact subnet string stored on each client record.
|
// reconstruct the exact subnet string stored on each client record.
|
||||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
|
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { sites, Newt, orgs } from "@server/db";
|
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { inflate } from "zlib";
|
import { inflate } from "zlib";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||||
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
|
|
||||||
export async function flushRequestLogToDb(): Promise<void> {
|
export async function flushRequestLogToDb(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
@@ -81,6 +82,7 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
|
|||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select({
|
.select({
|
||||||
orgId: sites.orgId,
|
orgId: sites.orgId,
|
||||||
|
orgSubnet: orgs.subnet,
|
||||||
settingsLogRetentionDaysRequest:
|
settingsLogRetentionDaysRequest:
|
||||||
orgs.settingsLogRetentionDaysRequest
|
orgs.settingsLogRetentionDaysRequest
|
||||||
})
|
})
|
||||||
@@ -118,6 +120,61 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||||
|
|
||||||
|
// Build a map from sourceIp → external endpoint string by joining clients
|
||||||
|
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||||
|
// of the client device and is used for GeoIP lookup.
|
||||||
|
const ipToEndpoint = new Map<string, string>();
|
||||||
|
|
||||||
|
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||||
|
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cidrSuffix) {
|
||||||
|
const uniqueSourceAddrs = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.sourceAddr) {
|
||||||
|
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueSourceAddrs.size > 0) {
|
||||||
|
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||||
|
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||||
|
return `${ip}${cidrSuffix}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedClients = await db
|
||||||
|
.select({
|
||||||
|
subnet: clients.subnet,
|
||||||
|
endpoint: clientSitesAssociationsCache.endpoint
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.innerJoin(
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clients.clientId
|
||||||
|
),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
inArray(clients.subnet, subnetQueries)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of matchedClients) {
|
||||||
|
if (c.endpoint) {
|
||||||
|
const ip = c.subnet.split("/")[0];
|
||||||
|
ipToEndpoint.set(ip, c.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (
|
if (
|
||||||
!entry.requestId ||
|
!entry.requestId ||
|
||||||
@@ -141,12 +198,27 @@ export const handleRequestLogMessage: MessageHandler = async (context) => {
|
|||||||
entry.path +
|
entry.path +
|
||||||
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||||
|
|
||||||
|
// Resolve the client's external endpoint for GeoIP lookup.
|
||||||
|
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||||
|
const sourceIp = entry.sourceAddr.includes(":")
|
||||||
|
? entry.sourceAddr.split(":")[0]
|
||||||
|
: entry.sourceAddr;
|
||||||
|
const endpoint = ipToEndpoint.get(sourceIp);
|
||||||
|
let location: string | undefined;
|
||||||
|
if (endpoint) {
|
||||||
|
const endpointIp = endpoint.includes(":")
|
||||||
|
? endpoint.split(":")[0]
|
||||||
|
: endpoint;
|
||||||
|
location = await getCountryCodeForIp(endpointIp);
|
||||||
|
}
|
||||||
|
|
||||||
await logRequestAudit(
|
await logRequestAudit(
|
||||||
{
|
{
|
||||||
action: true,
|
action: true,
|
||||||
reason: 108,
|
reason: 108,
|
||||||
resourceId: entry.resourceId,
|
siteResourceId: entry.resourceId,
|
||||||
orgId
|
orgId,
|
||||||
|
location
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
|
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
|
|||||||
lt(requestAuditLog.timestamp, data.timeEnd),
|
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||||
eq(requestAuditLog.orgId, data.orgId),
|
eq(requestAuditLog.orgId, data.orgId),
|
||||||
data.resourceId
|
data.resourceId
|
||||||
? eq(requestAuditLog.resourceId, data.resourceId)
|
? or(
|
||||||
|
eq(requestAuditLog.resourceId, data.resourceId),
|
||||||
|
eq(requestAuditLog.siteResourceId, data.resourceId)
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||||
@@ -118,6 +121,7 @@ export function queryRequest(data: Q) {
|
|||||||
actor: requestAuditLog.actor,
|
actor: requestAuditLog.actor,
|
||||||
actorId: requestAuditLog.actorId,
|
actorId: requestAuditLog.actorId,
|
||||||
resourceId: requestAuditLog.resourceId,
|
resourceId: requestAuditLog.resourceId,
|
||||||
|
siteResourceId: requestAuditLog.siteResourceId,
|
||||||
ip: requestAuditLog.ip,
|
ip: requestAuditLog.ip,
|
||||||
location: requestAuditLog.location,
|
location: requestAuditLog.location,
|
||||||
userAgent: requestAuditLog.userAgent,
|
userAgent: requestAuditLog.userAgent,
|
||||||
@@ -137,17 +141,22 @@ export function queryRequest(data: Q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
||||||
// If logs database is the same as main database, we can do a join
|
|
||||||
// Otherwise, we need to fetch resource details separately
|
|
||||||
const resourceIds = logs
|
const resourceIds = logs
|
||||||
.map(log => log.resourceId)
|
.map(log => log.resourceId)
|
||||||
.filter((id): id is number => id !== null && id !== undefined);
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
if (resourceIds.length === 0) {
|
const siteResourceIds = logs
|
||||||
|
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||||
|
.map(log => log.siteResourceId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch resource details from main database
|
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||||
|
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
const resourceDetails = await primaryDb
|
const resourceDetails = await primaryDb
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -157,17 +166,48 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
|||||||
.from(resources)
|
.from(resources)
|
||||||
.where(inArray(resources.resourceId, resourceIds));
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
// Create a map for quick lookup
|
for (const r of resourceDetails) {
|
||||||
const resourceMap = new Map(
|
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||||
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
niceId: siteResources.niceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
for (const r of siteResourceDetails) {
|
||||||
|
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich logs with resource details
|
// Enrich logs with resource details
|
||||||
return logs.map(log => ({
|
return logs.map(log => {
|
||||||
|
if (log.resourceId != null) {
|
||||||
|
const details = resourceMap.get(log.resourceId);
|
||||||
|
return {
|
||||||
...log,
|
...log,
|
||||||
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
|
resourceName: details?.name ?? null,
|
||||||
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
|
resourceNiceId: details?.niceId ?? null
|
||||||
}));
|
};
|
||||||
|
} else if (log.siteResourceId != null) {
|
||||||
|
const details = siteResourceMap.get(log.siteResourceId);
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
resourceId: log.siteResourceId,
|
||||||
|
resourceName: details?.name ?? null,
|
||||||
|
resourceNiceId: details?.niceId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...log, resourceName: null, resourceNiceId: null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countRequestQuery(data: Q) {
|
export function countRequestQuery(data: Q) {
|
||||||
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
|
|||||||
uniqueLocations,
|
uniqueLocations,
|
||||||
uniqueHosts,
|
uniqueHosts,
|
||||||
uniquePaths,
|
uniquePaths,
|
||||||
uniqueResources
|
uniqueResources,
|
||||||
|
uniqueSiteResources
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
primaryLogsDb
|
primaryLogsDb
|
||||||
.selectDistinct({ actor: requestAuditLog.actor })
|
.selectDistinct({ actor: requestAuditLog.actor })
|
||||||
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
|
|||||||
})
|
})
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
primaryLogsDb
|
||||||
|
.selectDistinct({
|
||||||
|
id: requestAuditLog.siteResourceId
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
|
||||||
.limit(DISTINCT_LIMIT + 1)
|
.limit(DISTINCT_LIMIT + 1)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
|
|||||||
.map(row => row.id)
|
.map(row => row.id)
|
||||||
.filter((id): id is number => id !== null);
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
const siteResourceIds = uniqueSiteResources
|
||||||
|
.map(row => row.id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||||
|
|
||||||
if (resourceIds.length > 0) {
|
if (resourceIds.length > 0) {
|
||||||
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
|
|||||||
.from(resources)
|
.from(resources)
|
||||||
.where(inArray(resources.resourceId, resourceIds));
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
resourcesWithNames = resourceDetails.map(r => ({
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...resourceDetails.map(r => ({
|
||||||
id: r.resourceId,
|
id: r.resourceId,
|
||||||
name: r.name
|
name: r.name
|
||||||
}));
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...siteResourceDetails.map(r => ({
|
||||||
|
id: r.siteResourceId,
|
||||||
|
name: r.name
|
||||||
|
}))
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
|
|||||||
actor: string | null;
|
actor: string | null;
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
resourceNiceId: string | null;
|
resourceNiceId: string | null;
|
||||||
resourceName: string | null;
|
resourceName: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const auditLogBuffer: Array<{
|
|||||||
metadata: any;
|
metadata: any;
|
||||||
action: boolean;
|
action: boolean;
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
reason: number;
|
reason: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
originalRequestURL: string;
|
originalRequestURL: string;
|
||||||
@@ -187,6 +188,7 @@ export async function logRequestAudit(
|
|||||||
action: boolean;
|
action: boolean;
|
||||||
reason: number;
|
reason: number;
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
user?: { username: string; userId: string };
|
user?: { username: string; userId: string };
|
||||||
@@ -263,6 +265,7 @@ export async function logRequestAudit(
|
|||||||
metadata: sanitizeString(metadata),
|
metadata: sanitizeString(metadata),
|
||||||
action: data.action,
|
action: data.action,
|
||||||
resourceId: data.resourceId,
|
resourceId: data.resourceId,
|
||||||
|
siteResourceId: data.siteResourceId,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
location: sanitizeString(data.location),
|
location: sanitizeString(data.location),
|
||||||
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
||||||
|
|||||||
@@ -512,7 +512,11 @@ export default function GeneralPage() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
href={
|
||||||
|
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||||
|
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
|
||||||
|
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||||
|
}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user