Merge branch 'dev' of https://github.com/fosrl/pangolin into dev

This commit is contained in:
miloschwartz
2025-12-18 16:13:59 -05:00
16 changed files with 216 additions and 149 deletions

View File

@@ -31,7 +31,7 @@
[![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack) [![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) [![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@pangolin-net)
</div> </div>

View File

@@ -9,10 +9,15 @@ services:
PARSERS: crowdsecurity/whitelists PARSERS: crowdsecurity/whitelists
ENROLL_TAGS: docker ENROLL_TAGS: docker
healthcheck: healthcheck:
interval: 10s test:
retries: 15 - CMD
timeout: 10s - cscli
test: ["CMD", "cscli", "capi", "status"] - lapi
- status
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
labels: labels:
- "traefik.enable=false" # Disable traefik for crowdsec - "traefik.enable=false" # Disable traefik for crowdsec
volumes: volumes:

View File

@@ -44,7 +44,7 @@ http:
crowdsecAppsecUnreachableBlock: true # Block on unreachable crowdsecAppsecUnreachableBlock: true # Block on unreachable
crowdsecAppsecBodyLimit: 10485760 crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiHost: crowdsec:8080 # CrowdSec
crowdsecLapiScheme: http # CrowdSec API scheme crowdsecLapiScheme: http # CrowdSec API scheme
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
@@ -106,4 +106,13 @@ http:
api-service: api-service:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3000" # API/WebSocket server - url: "http://pangolin:3000" # API/WebSocket server
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2

View File

@@ -158,9 +158,9 @@
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourceHTTP": "HTTPS Resource", "resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests to the app over HTTPS using a subdomain or base domain.", "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
"resourceRaw": "Raw TCP/UDP Resource", "resourceRaw": "Raw TCP/UDP Resource",
"resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.", "resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
"resourceCreate": "Create Resource", "resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource", "resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceSeeAll": "See All Resources", "resourceSeeAll": "See All Resources",
@@ -946,7 +946,7 @@
"pincodeAuth": "Authenticator Code", "pincodeAuth": "Authenticator Code",
"pincodeSubmit2": "Submit Code", "pincodeSubmit2": "Submit Code",
"passwordResetSubmit": "Request Reset", "passwordResetSubmit": "Request Reset",
"passwordResetAlreadyHaveCode": "Enter Password Reset Code", "passwordResetAlreadyHaveCode": "Enter Code",
"passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequired": "Please contact your administrator",
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
"passwordBack": "Back to Password", "passwordBack": "Back to Password",

View File

@@ -20,6 +20,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const driver: "pg" | "sqlite" = "sqlite";
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];

View File

@@ -12,6 +12,11 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
let primaryDb = db;
if (driver == "pg") {
primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup
}
const queryAccessAuditLogsQuery = z.object({ const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
timeStart: z timeStart: z
@@ -74,12 +79,12 @@ async function query(query: Q) {
); );
} }
const [all] = await db const [all] = await primaryDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions);
const [blocked] = await db const [blocked] = await primaryDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false))); .where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -88,7 +93,9 @@ async function query(query: Q) {
.mapWith(Number) .mapWith(Number)
.as("total"); .as("total");
const requestsPerCountry = await db const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryDb
.selectDistinct({ .selectDistinct({
code: requestAuditLog.location, code: requestAuditLog.location,
count: totalQ count: totalQ
@@ -96,7 +103,16 @@ async function query(query: Q) {
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, not(isNull(requestAuditLog.location)))) .where(and(baseConditions, not(isNull(requestAuditLog.location))))
.groupBy(requestAuditLog.location) .groupBy(requestAuditLog.location)
.orderBy(desc(totalQ)); .orderBy(desc(totalQ))
.limit(DISTINCT_LIMIT+1);
if (requestsPerCountry.length > DISTINCT_LIMIT) {
// throw an error
throw createHttpError(
HttpCode.BAD_REQUEST,
`Too many distinct countries. Please narrow your query.`
);
}
const groupByDayFunction = const groupByDayFunction =
driver === "pg" driver === "pg"
@@ -106,7 +122,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await db const requestsPerDay = await primaryDb
.select({ .select({
day: groupByDayFunction.as("day"), day: groupByDayFunction.as("day"),
allowedCount: allowedCount:

View File

@@ -1,4 +1,4 @@
import { db, requestAuditLog, resources } from "@server/db"; import { db, driver, requestAuditLog, resources } 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";
@@ -13,6 +13,11 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
let primaryDb = db;
if (driver == "pg") {
primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup
}
export const queryAccessAuditLogsQuery = z.object({ export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
timeStart: z timeStart: z
@@ -107,7 +112,7 @@ function getWhere(data: Q) {
} }
export function queryRequest(data: Q) { export function queryRequest(data: Q) {
return db return primaryDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
@@ -143,7 +148,7 @@ export function queryRequest(data: Q) {
} }
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
const countQuery = db const countQuery = primaryDb
.select({ count: count() }) .select({ count: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -173,50 +178,61 @@ async function queryUniqueFilterAttributes(
eq(requestAuditLog.orgId, orgId) eq(requestAuditLog.orgId, orgId)
); );
// Get unique actors const DISTINCT_LIMIT = 500;
const uniqueActors = await db
.selectDistinct({
actor: requestAuditLog.actor
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique locations // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!!
const uniqueLocations = await db
.selectDistinct({
locations: requestAuditLog.location
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique actors // Run all queries in parallel
const uniqueHosts = await db const [
.selectDistinct({ uniqueActors,
hosts: requestAuditLog.host uniqueLocations,
}) uniqueHosts,
.from(requestAuditLog) uniquePaths,
.where(baseConditions); uniqueResources
] = await Promise.all([
primaryDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1)
]);
// Get unique actors if (
const uniquePaths = await db uniqueActors.length > DISTINCT_LIMIT ||
.selectDistinct({ uniqueLocations.length > DISTINCT_LIMIT ||
paths: requestAuditLog.path uniqueHosts.length > DISTINCT_LIMIT ||
}) uniquePaths.length > DISTINCT_LIMIT ||
.from(requestAuditLog) uniqueResources.length > DISTINCT_LIMIT
.where(baseConditions); ) {
throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
// Get unique resources with names }
const uniqueResources = await db
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
return { return {
actors: uniqueActors actors: uniqueActors
@@ -295,6 +311,12 @@ export async function queryRequestAuditLogs(
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
// if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message
if (error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range.") {
return next(
createHttpError(HttpCode.BAD_REQUEST, error.message)
);
}
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );

View File

@@ -6,8 +6,8 @@ export type GetCertificateResponse = {
status: string; // pending, requested, valid, expired, failed status: string; // pending, requested, valid, expired, failed
expiresAt: string | null; expiresAt: string | null;
lastRenewalAttempt: Date | null; lastRenewalAttempt: Date | null;
createdAt: string; createdAt: number;
updatedAt: string; updatedAt: number;
errorMessage?: string | null; errorMessage?: string | null;
renewalCount: number; renewalCount: number;
}; };

View File

@@ -194,11 +194,23 @@ export async function getOlmToken(
.where(inArray(exitNodes.exitNodeId, exitNodeIds)); .where(inArray(exitNodes.exitNodeId, exitNodeIds));
} }
// Map exitNodeId to siteIds
const exitNodeIdToSiteIds: Record<number, number[]> = {};
for (const { sites: site } of clientSites) {
if (site.exitNodeId !== null) {
if (!exitNodeIdToSiteIds[site.exitNodeId]) {
exitNodeIdToSiteIds[site.exitNodeId] = [];
}
exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId);
}
}
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return { return {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port, relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: exitNode.endpoint endpoint: exitNode.endpoint,
siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? []
}; };
}); });

View File

@@ -303,6 +303,24 @@ export default function Page() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
@@ -331,24 +349,6 @@ export default function Page() {
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -1312,6 +1312,35 @@ export default function Page() {
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{resourceTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</>
)}
<SettingsSectionForm> <SettingsSectionForm>
<Form {...baseForm}> <Form {...baseForm}>
<form <form
@@ -1348,35 +1377,6 @@ export default function Page() {
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
{resourceTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</>
)}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -1684,7 +1684,7 @@ export default function Page() {
</div> </div>
</> </>
) : ( ) : (
<div className="text-center p-4"> <div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t("targetNoOne")} {t("targetNoOne")}
</p> </p>

View File

@@ -674,6 +674,26 @@ WantedBy=default.target`
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
<Form {...form}> <Form {...form}>
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -748,26 +768,6 @@ WantedBy=default.target`
)} )}
</form> </form>
</Form> </Form>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -209,22 +209,22 @@ export default function Page() {
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
<div> {/* <div> */}
<div className="mb-2"> {/* <div className="mb-2"> */}
<span className="text-sm font-medium"> {/* <span className="text-sm font-medium"> */}
{t("idpType")} {/* {t("idpType")} */}
</span> {/* </span> */}
</div> {/* </div> */}
{/* */}
<StrategySelect {/* <StrategySelect */}
options={providerTypes} {/* options={providerTypes} */}
defaultValue={form.getValues("type")} {/* defaultValue={form.getValues("type")} */}
onChange={(value) => { {/* onChange={(value) => { */}
form.setValue("type", value as "oidc"); {/* form.setValue("type", value as "oidc"); */}
}} {/* }} */}
cols={3} {/* cols={3} */}
/> {/* /> */}
</div> {/* </div> */}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -546,6 +546,7 @@ export default function ResetPasswordForm({
)} )}
<Button <Button
type="button" type="button"
variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
const email = const email =

View File

@@ -28,7 +28,7 @@ export function SettingsSectionForm({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div> <div className={cn("md:max-w-1/2 space-y-4", className)}>{children}</div>
); );
} }

View File

@@ -59,13 +59,14 @@ export default function CertificateStatus({
} }
}; };
const shouldShowRefreshButton = (status: string, updatedAt: string) => { const shouldShowRefreshButton = (status: string, updatedAt: number) => {
return ( return (
status === "failed" || status === "failed" ||
status === "expired" || status === "expired" ||
(status === "requested" && (status === "requested" &&
updatedAt && updatedAt &&
new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000) new Date(updatedAt * 1000).getTime() <
Date.now() - 5 * 60 * 1000)
); );
}; };