diff --git a/README.md b/README.md index 1827f2570..41ea77447 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra Private resources +### 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) + +AI Session Logs + ### 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. diff --git a/public/screenshots/expanded-session-logs.png b/public/screenshots/expanded-session-logs.png new file mode 100644 index 000000000..9a5bf0877 Binary files /dev/null and b/public/screenshots/expanded-session-logs.png differ diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 4f1adbd53..f722c656a 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -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( diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index 9685722c5..f5b5b2868 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -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)) {