mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-28 23:15:10 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc56a2bed0 | |||
| da3e3ff33f | |||
| 872e0f9ae1 | |||
| 5b3713a72f | |||
| f02be1fdbf | |||
| 0bf04cf0cd | |||
| 7cda28d685 | |||
| 60bc74c4df | |||
| 48ab6c501f |
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
|
|||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
|
### Identity-aware AI gateway
|
||||||
|
|
||||||
|
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
|
||||||
|
|
||||||
|
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
|
||||||
|
* Keyless access by authenticating users with the Pangolin desktop client
|
||||||
|
* Or, provide users with personal API keys
|
||||||
|
* Control costs and token usage by setting budgets
|
||||||
|
* Audit with detailed session history and analytics
|
||||||
|
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
|
||||||
|
|
||||||
|
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|||||||
Generated
+70
-26
@@ -7569,6 +7569,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.10.0",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
@@ -17131,36 +17197,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-adapter": {
|
"node_modules/socket.io-adapter": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "~4.4.1",
|
"debug": "~4.4.1",
|
||||||
"ws": "~8.18.3"
|
"ws": "~8.21.0"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socket.io-adapter/node_modules/ws": {
|
|
||||||
"version": "8.18.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
|
||||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"bufferutil": "^4.0.1",
|
|
||||||
"utf-8-validate": ">=5.0.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"bufferutil": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"utf-8-validate": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
|
|||||||
export const RuleSchema = z
|
export const RuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional(),
|
||||||
enabled: z.boolean().optional().default(true)
|
enabled: z.boolean().optional().default(true)
|
||||||
@@ -136,7 +136,7 @@ export const RuleSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country" || rule.match === "country_is_not") {
|
||||||
if (!hasMaxmindCountryDb) {
|
if (!hasMaxmindCountryDb) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-1
@@ -3,6 +3,8 @@ import config from "./config";
|
|||||||
import { getHostMeta } from "./hostMeta";
|
import { getHostMeta } from "./hostMeta";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
|
aiProviders,
|
||||||
|
aiUsageRecords,
|
||||||
alertRules,
|
alertRules,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
blueprints,
|
blueprints,
|
||||||
@@ -11,7 +13,16 @@ import {
|
|||||||
siteResources
|
siteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
import { sites, users, orgs, resources, clients, idp } from "@server/db";
|
||||||
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
|
import {
|
||||||
|
eq,
|
||||||
|
count,
|
||||||
|
countDistinct,
|
||||||
|
notInArray,
|
||||||
|
and,
|
||||||
|
isNotNull,
|
||||||
|
isNull,
|
||||||
|
gte
|
||||||
|
} from "drizzle-orm";
|
||||||
import { APP_VERSION } from "./consts";
|
import { APP_VERSION } from "./consts";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
@@ -172,6 +183,25 @@ class TelemetryClient {
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(blueprints);
|
.from(blueprints);
|
||||||
|
|
||||||
|
const [aiProvidersCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(aiProviders);
|
||||||
|
const [orgsWithAiProviders] = await db
|
||||||
|
.select({ count: countDistinct(aiProviders.orgId) })
|
||||||
|
.from(aiProviders);
|
||||||
|
|
||||||
|
const usageWindowStart =
|
||||||
|
Math.floor(Date.now() / 1000) -
|
||||||
|
this.collectionIntervalDays * 24 * 60 * 60;
|
||||||
|
const [aiUsageRecordsRecent] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||||
|
const [orgsWithRecentAiUsage] = await db
|
||||||
|
.select({ count: countDistinct(aiUsageRecords.orgId) })
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
|
||||||
|
|
||||||
const supporterKey = config.getSupporterData();
|
const supporterKey = config.getSupporterData();
|
||||||
|
|
||||||
const allPrivateResources = await db.select().from(siteResources);
|
const allPrivateResources = await db.select().from(siteResources);
|
||||||
@@ -182,6 +212,7 @@ class TelemetryClient {
|
|||||||
let numPrivResourceCidr = 0;
|
let numPrivResourceCidr = 0;
|
||||||
let numPrivResourceHttp = 0;
|
let numPrivResourceHttp = 0;
|
||||||
let numPrivResourceSsh = 0;
|
let numPrivResourceSsh = 0;
|
||||||
|
let numPrivResourceInference = 0;
|
||||||
for (const res of allPrivateResources) {
|
for (const res of allPrivateResources) {
|
||||||
if (res.mode === "host") {
|
if (res.mode === "host") {
|
||||||
numPrivResourceHosts += 1;
|
numPrivResourceHosts += 1;
|
||||||
@@ -191,6 +222,8 @@ class TelemetryClient {
|
|||||||
numPrivResourceHttp += 1;
|
numPrivResourceHttp += 1;
|
||||||
} else if (res.mode === "ssh") {
|
} else if (res.mode === "ssh") {
|
||||||
numPrivResourceSsh += 1;
|
numPrivResourceSsh += 1;
|
||||||
|
} else if (res.mode === "inference") {
|
||||||
|
numPrivResourceInference += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.alias) {
|
if (res.alias) {
|
||||||
@@ -211,6 +244,11 @@ class TelemetryClient {
|
|||||||
numPrivateResourceCidr: numPrivResourceCidr,
|
numPrivateResourceCidr: numPrivResourceCidr,
|
||||||
numPrivateResourceHttp: numPrivResourceHttp,
|
numPrivateResourceHttp: numPrivResourceHttp,
|
||||||
numPrivateResourceSsh: numPrivResourceSsh,
|
numPrivateResourceSsh: numPrivResourceSsh,
|
||||||
|
numPrivateResourceInference: numPrivResourceInference,
|
||||||
|
numAiProviders: aiProvidersCount.count,
|
||||||
|
numOrgsWithAiProviders: orgsWithAiProviders.count,
|
||||||
|
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
|
||||||
|
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
|
||||||
numAlertRules: numAlertRules.count,
|
numAlertRules: numAlertRules.count,
|
||||||
numUserDevices: userDevicesCount.count,
|
numUserDevices: userDevicesCount.count,
|
||||||
numMachineClients: machineClients.count,
|
numMachineClients: machineClients.count,
|
||||||
@@ -323,6 +361,17 @@ class TelemetryClient {
|
|||||||
num_resources_non_http: stats.resources.filter(
|
num_resources_non_http: stats.resources.filter(
|
||||||
(r) => r.mode !== "http"
|
(r) => r.mode !== "http"
|
||||||
).length,
|
).length,
|
||||||
|
num_resources_ai_gateway: stats.resources.filter(
|
||||||
|
(r) => r.mode === "inference"
|
||||||
|
).length,
|
||||||
|
num_private_resources_ai_gateway:
|
||||||
|
stats.numPrivateResourceInference,
|
||||||
|
num_ai_providers: stats.numAiProviders,
|
||||||
|
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
|
||||||
|
num_ai_usage_records_recent:
|
||||||
|
stats.numAiUsageRecordsRecent,
|
||||||
|
num_orgs_with_recent_ai_usage:
|
||||||
|
stats.numOrgsWithRecentAiUsage,
|
||||||
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
|
||||||
.length,
|
.length,
|
||||||
num_local_sites: stats.sites.filter(
|
num_local_sites: stats.sites.filter(
|
||||||
|
|||||||
@@ -50,8 +50,6 @@ import {
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import {
|
import {
|
||||||
createBrowserGatewayTargetFormSchema,
|
createBrowserGatewayTargetFormSchema,
|
||||||
@@ -59,7 +57,6 @@ import {
|
|||||||
selectedSiteSchema,
|
selectedSiteSchema,
|
||||||
type SshSettingsFormValues
|
type SshSettingsFormValues
|
||||||
} from "@app/lib/browserGatewayTargetFormSchema";
|
} from "@app/lib/browserGatewayTargetFormSchema";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -328,19 +325,20 @@ export default function Page() {
|
|||||||
const rawResourcesAllowed =
|
const rawResourcesAllowed =
|
||||||
env.flags.allowRawResources &&
|
env.flags.allowRawResources &&
|
||||||
(build !== "saas" || remoteExitNodes.length > 0);
|
(build !== "saas" || remoteExitNodes.length > 0);
|
||||||
const enterpriseModesAllowed =
|
|
||||||
!env.flags.disableEnterpriseFeatures;
|
|
||||||
|
|
||||||
const availableTypes = useMemo((): NewResourceType[] => {
|
const availableTypes = useMemo((): NewResourceType[] => {
|
||||||
const base: NewResourceType[] = ["http", "inference"];
|
const base: NewResourceType[] = [
|
||||||
if (enterpriseModesAllowed) {
|
"http",
|
||||||
base.push("ssh", "rdp", "vnc");
|
"inference",
|
||||||
}
|
"ssh",
|
||||||
|
"rdp",
|
||||||
|
"vnc"
|
||||||
|
];
|
||||||
if (rawResourcesAllowed) {
|
if (rawResourcesAllowed) {
|
||||||
base.push("tcp", "udp");
|
base.push("tcp", "udp");
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [enterpriseModesAllowed, rawResourcesAllowed]);
|
}, [rawResourcesAllowed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!availableTypes.includes(resourceType)) {
|
if (!availableTypes.includes(resourceType)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user