mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-28 23:15:10 +02:00
Merge pull request #3650 from fosrl/dev
Update readme, tel, and fix EE feature flag
This commit is contained in:
@@ -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%" />
|
||||
|
||||
### 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 keykless 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
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
+50
-1
@@ -3,6 +3,8 @@ import config from "./config";
|
||||
import { getHostMeta } from "./hostMeta";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
aiProviders,
|
||||
aiUsageRecords,
|
||||
alertRules,
|
||||
apiKeys,
|
||||
blueprints,
|
||||
@@ -11,7 +13,16 @@ import {
|
||||
siteResources
|
||||
} 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 crypto from "crypto";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
@@ -172,6 +183,25 @@ class TelemetryClient {
|
||||
.select({ count: count() })
|
||||
.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 allPrivateResources = await db.select().from(siteResources);
|
||||
@@ -182,6 +212,7 @@ class TelemetryClient {
|
||||
let numPrivResourceCidr = 0;
|
||||
let numPrivResourceHttp = 0;
|
||||
let numPrivResourceSsh = 0;
|
||||
let numPrivResourceInference = 0;
|
||||
for (const res of allPrivateResources) {
|
||||
if (res.mode === "host") {
|
||||
numPrivResourceHosts += 1;
|
||||
@@ -191,6 +222,8 @@ class TelemetryClient {
|
||||
numPrivResourceHttp += 1;
|
||||
} else if (res.mode === "ssh") {
|
||||
numPrivResourceSsh += 1;
|
||||
} else if (res.mode === "inference") {
|
||||
numPrivResourceInference += 1;
|
||||
}
|
||||
|
||||
if (res.alias) {
|
||||
@@ -211,6 +244,11 @@ class TelemetryClient {
|
||||
numPrivateResourceCidr: numPrivResourceCidr,
|
||||
numPrivateResourceHttp: numPrivResourceHttp,
|
||||
numPrivateResourceSsh: numPrivResourceSsh,
|
||||
numPrivateResourceInference: numPrivResourceInference,
|
||||
numAiProviders: aiProvidersCount.count,
|
||||
numOrgsWithAiProviders: orgsWithAiProviders.count,
|
||||
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
|
||||
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
|
||||
numAlertRules: numAlertRules.count,
|
||||
numUserDevices: userDevicesCount.count,
|
||||
numMachineClients: machineClients.count,
|
||||
@@ -323,6 +361,17 @@ class TelemetryClient {
|
||||
num_resources_non_http: stats.resources.filter(
|
||||
(r) => r.mode !== "http"
|
||||
).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")
|
||||
.length,
|
||||
num_local_sites: stats.sites.filter(
|
||||
|
||||
@@ -50,8 +50,6 @@ import {
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
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 {
|
||||
createBrowserGatewayTargetFormSchema,
|
||||
@@ -59,7 +57,6 @@ import {
|
||||
selectedSiteSchema,
|
||||
type SshSettingsFormValues
|
||||
} from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -328,19 +325,20 @@ export default function Page() {
|
||||
const rawResourcesAllowed =
|
||||
env.flags.allowRawResources &&
|
||||
(build !== "saas" || remoteExitNodes.length > 0);
|
||||
const enterpriseModesAllowed =
|
||||
!env.flags.disableEnterpriseFeatures;
|
||||
|
||||
const availableTypes = useMemo((): NewResourceType[] => {
|
||||
const base: NewResourceType[] = ["http", "inference"];
|
||||
if (enterpriseModesAllowed) {
|
||||
base.push("ssh", "rdp", "vnc");
|
||||
}
|
||||
const base: NewResourceType[] = [
|
||||
"http",
|
||||
"inference",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc"
|
||||
];
|
||||
if (rawResourcesAllowed) {
|
||||
base.push("tcp", "udp");
|
||||
}
|
||||
return base;
|
||||
}, [enterpriseModesAllowed, rawResourcesAllowed]);
|
||||
}, [rawResourcesAllowed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTypes.includes(resourceType)) {
|
||||
|
||||
Reference in New Issue
Block a user