Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 778a840ed7 | |||
| 9d19195089 | |||
| 54bbe82504 | |||
| de57df2520 | |||
| 9e392a967d |
@@ -34,6 +34,14 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: AI Disclosure
|
||||||
|
description: |
|
||||||
|
If you used AI to help write this issue, please disclose it here. This is important for transparency and helps maintain the integrity of the issue tracking process.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Expected Behavior
|
||||||
|
|||||||
@@ -37,11 +37,22 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>
|
<strong>
|
||||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
Get started with Pangolin Cloud at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
Pangolin is an open-source SASE platform, built on WireGuard®, with a simple mission: connect and protect your users, wherever they are. It brings networking and security together as one system including a zero-trust VPN, zero-trust reverse proxy, privileged access management, and an identity-aware AI gateway, all sharing one identity and policy model. It's the same idea behind platforms like Cloudflare One, Zscaler, and Prisma but open, self-hostable, and built to stay light and easy to deploy.
|
||||||
|
|
||||||
|
### Networking and security that's unified, open, and simple
|
||||||
|
|
||||||
|
Legacy SASE platforms got the idea right: connectivity and security belong together. But they delivered it as a heavyweight, closed, cloud-locked stack assembled from years of patchwork. Pangolin exists to do that unification differently, in the open, self-hostable, and simple enough that administrators actually enjoy running it.
|
||||||
|
|
||||||
|
* **Open source, not a black box**: the code is open and auditable, so you can see exactly how your traffic is handled and how access decisions get made, instead of trusting a closed cloud control plane.
|
||||||
|
* **Networking and security as one platform**: sites, reverse proxy, client access, RBAC, and the AI gateway share one identity and policy model, so protecting users and connecting them are executed together.
|
||||||
|
* **Lightweight by design**: the whole platform is built to stay small and fast: easy to self-host on a small server, with a lightweight, user-space connector that goes in your private networks.
|
||||||
|
* **Enjoyable to use**: a clean, modern interface and a setup flow that gets out of your way, so managing access feels simple instead of like fighting a legacy admin console.
|
||||||
|
* **Zero trust from day one**: access is granted per resource, not per network, with identity provider integration, role-based access control, and full audit logging.
|
||||||
|
* **Run it your way**: self-host the Community Edition for free, step up to the Enterprise Edition for advanced features, or use Pangolin Cloud if you'd rather not manage infrastructure at all.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -53,9 +64,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
## Deployment Options
|
## Deployment Options
|
||||||
|
|
||||||
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
|
- **Pangolin Cloud** - Fully managed service with no infrastructure required.
|
||||||
- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3.
|
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3.
|
||||||
- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
- **Self-Host: Enterprise Edition** - Open-core, and licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 532 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 556 KiB After Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 800 KiB |
@@ -112,6 +112,11 @@ export class Config {
|
|||||||
? "true"
|
? "true"
|
||||||
: "false";
|
: "false";
|
||||||
|
|
||||||
|
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI = parsedConfig.flags
|
||||||
|
?.disable_virtual_api_keys_ui
|
||||||
|
? "true"
|
||||||
|
: "false";
|
||||||
|
|
||||||
this.rawConfig = parsedConfig;
|
this.rawConfig = parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,7 @@ export const configSchema = z
|
|||||||
disable_config_managed_domains: z.boolean().optional(),
|
disable_config_managed_domains: z.boolean().optional(),
|
||||||
disable_product_help_banners: z.boolean().optional(),
|
disable_product_help_banners: z.boolean().optional(),
|
||||||
disable_enterprise_features: z.boolean().optional(),
|
disable_enterprise_features: z.boolean().optional(),
|
||||||
|
disable_virtual_api_keys_ui: z.boolean().optional(),
|
||||||
enable_acme_cert_sync: z.boolean().optional().default(true),
|
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||||
disable_private_http_placeholder: z
|
disable_private_http_placeholder: z
|
||||||
.boolean()
|
.boolean()
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ const processMessage = async (
|
|||||||
|
|
||||||
const handler = messageHandlers[message.type];
|
const handler = messageHandlers[message.type];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error(`Unsupported message type: ${message.type}`);
|
logger.debug(`No handler found for message type: ${message.type}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await handler({
|
const response = await handler({
|
||||||
|
|||||||
@@ -353,7 +353,10 @@ const setupConnection = async (
|
|||||||
|
|
||||||
const handler = messageHandlers[message.type];
|
const handler = messageHandlers[message.type];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
throw new Error(`Unsupported message type: ${message.type}`);
|
logger.debug(
|
||||||
|
`No handler found for message type: ${message.type}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await handler({
|
const response = await handler({
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export default async function KeysPage(props: KeysPageProps) {
|
|||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const env = pullEnv();
|
||||||
|
if (env.flags.disableVirtualApiKeysUi) {
|
||||||
|
redirect(`/${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
let keysData: ListMyVirtualApiKeysResponse | null = null;
|
let keysData: ListMyVirtualApiKeysResponse | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
@@ -90,7 +95,6 @@ export default async function KeysPage(props: KeysPageProps) {
|
|||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = pullEnv();
|
|
||||||
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ export default async function KeysPage(props: KeysPageProps) {
|
|||||||
showSidebar={false}
|
showSidebar={false}
|
||||||
launcherMode
|
launcherMode
|
||||||
showViewAsAdmin={isAdminOrOwner}
|
showViewAsAdmin={isAdminOrOwner}
|
||||||
|
env={env}
|
||||||
>
|
>
|
||||||
<UserVirtualApiKeys orgId={orgId} initialData={keysData} />
|
<UserVirtualApiKeys orgId={orgId} initialData={keysData} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
showSidebar={false}
|
showSidebar={false}
|
||||||
launcherMode
|
launcherMode
|
||||||
showViewAsAdmin={isAdminOrOwner}
|
showViewAsAdmin={isAdminOrOwner}
|
||||||
|
env={env}
|
||||||
>
|
>
|
||||||
{overview && launcherData ? (
|
{overview && launcherData ? (
|
||||||
<ResourceLauncher
|
<ResourceLauncher
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export default function SecurityPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LogRetentionSectionForm({ org }: SectionFormProps) {
|
function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||||
|
const { updateOrg } = useOrgContext();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
SecurityFormSchema.pick({
|
SecurityFormSchema.pick({
|
||||||
@@ -173,6 +174,11 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
|||||||
// Update organization
|
// Update organization
|
||||||
await api.post(`/org/${org.orgId}`, reqData);
|
await api.post(`/org/${org.orgId}`, reqData);
|
||||||
|
|
||||||
|
// Update the org context immediately so the dropdowns reflect
|
||||||
|
// the saved values without waiting on a re-fetch that could
|
||||||
|
// race a lagging read replica
|
||||||
|
updateOrg(reqData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("orgUpdated"),
|
title: t("orgUpdated"),
|
||||||
description: t("orgUpdatedDescription")
|
description: t("orgUpdatedDescription")
|
||||||
@@ -199,7 +205,10 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
action={formAction}
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
formAction();
|
||||||
|
}}
|
||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
id="org-log-retention-settings-form"
|
id="org-log-retention-settings-form"
|
||||||
>
|
>
|
||||||
@@ -827,6 +836,7 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
|||||||
|
|
||||||
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { updateOrg } = useOrgContext();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
SecurityFormSchema.pick({
|
SecurityFormSchema.pick({
|
||||||
@@ -899,6 +909,11 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
|||||||
// Update organization
|
// Update organization
|
||||||
await api.post(`/org/${org.orgId}`, reqData);
|
await api.post(`/org/${org.orgId}`, reqData);
|
||||||
|
|
||||||
|
// Update the org context immediately so the dropdowns reflect
|
||||||
|
// the saved values without waiting on a re-fetch that could
|
||||||
|
// race a lagging read replica
|
||||||
|
updateOrg(reqData);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("orgUpdated"),
|
title: t("orgUpdated"),
|
||||||
description: t("orgUpdatedDescription")
|
description: t("orgUpdatedDescription")
|
||||||
@@ -942,7 +957,10 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
action={formAction}
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
formAction();
|
||||||
|
}}
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
id="security-settings-section-form"
|
id="security-settings-section-form"
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
|||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Resource Access"
|
const env = pullEnv();
|
||||||
};
|
const title =
|
||||||
|
env.branding.resourceAuthPage?.titleText ||
|
||||||
|
env.branding.appName ||
|
||||||
|
"Resource Access";
|
||||||
|
|
||||||
|
return { title };
|
||||||
|
}
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
|||||||
@@ -53,17 +53,21 @@ export type OrgNavSectionsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Merged from 'user-management-and-resources' branch
|
// Merged from 'user-management-and-resources' branch
|
||||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
export const orgLangingNavItems = (env?: Env): SidebarNavItem[] => [
|
||||||
{
|
{
|
||||||
title: "sidebarAccount",
|
title: "sidebarAccount",
|
||||||
href: "/{orgId}",
|
href: "/{orgId}",
|
||||||
icon: <LayoutGrid className="size-4 flex-none" />
|
icon: <LayoutGrid className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
...(!env?.flags.disableVirtualApiKeysUi
|
||||||
title: "sidebarMyApiKeys",
|
? [
|
||||||
href: "/{orgId}/keys",
|
{
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
title: "sidebarMyApiKeys",
|
||||||
}
|
href: "/{orgId}/keys",
|
||||||
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
];
|
];
|
||||||
|
|
||||||
export const orgNavSections = (
|
export const orgNavSections = (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
|
import { Env } from "@app/lib/types/env";
|
||||||
import {
|
import {
|
||||||
orgLangingNavItems,
|
orgLangingNavItems,
|
||||||
type CommandBarNavSection,
|
type CommandBarNavSection,
|
||||||
@@ -25,6 +26,7 @@ interface LayoutProps {
|
|||||||
defaultSidebarCollapsed?: boolean;
|
defaultSidebarCollapsed?: boolean;
|
||||||
launcherMode?: boolean;
|
launcherMode?: boolean;
|
||||||
showViewAsAdmin?: boolean;
|
showViewAsAdmin?: boolean;
|
||||||
|
env?: Env;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function Layout({
|
export async function Layout({
|
||||||
@@ -38,7 +40,8 @@ export async function Layout({
|
|||||||
showTopBar = true,
|
showTopBar = true,
|
||||||
defaultSidebarCollapsed = false,
|
defaultSidebarCollapsed = false,
|
||||||
launcherMode = false,
|
launcherMode = false,
|
||||||
showViewAsAdmin = false
|
showViewAsAdmin = false,
|
||||||
|
env
|
||||||
}: LayoutProps) {
|
}: LayoutProps) {
|
||||||
const allCookies = await cookies();
|
const allCookies = await cookies();
|
||||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||||
@@ -49,7 +52,7 @@ export async function Layout({
|
|||||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||||
|
|
||||||
const launcherNavItems: SidebarNavItem[] = launcherMode
|
const launcherNavItems: SidebarNavItem[] = launcherMode
|
||||||
? orgLangingNavItems
|
? orgLangingNavItems(env)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export function pullEnv(): Env {
|
|||||||
: false,
|
: false,
|
||||||
disableEnterpriseFeatures:
|
disableEnterpriseFeatures:
|
||||||
process.env.DISABLE_ENTERPRISE_FEATURES === "true"
|
process.env.DISABLE_ENTERPRISE_FEATURES === "true"
|
||||||
|
? true
|
||||||
|
: false,
|
||||||
|
disableVirtualApiKeysUi:
|
||||||
|
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI === "true"
|
||||||
? true
|
? true
|
||||||
: false
|
: false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type Env = {
|
|||||||
usePangolinDns: boolean;
|
usePangolinDns: boolean;
|
||||||
disableProductHelpBanners: boolean;
|
disableProductHelpBanners: boolean;
|
||||||
disableEnterpriseFeatures: boolean;
|
disableEnterpriseFeatures: boolean;
|
||||||
|
disableVirtualApiKeysUi: boolean;
|
||||||
};
|
};
|
||||||
branding: {
|
branding: {
|
||||||
appName?: string;
|
appName?: string;
|
||||||
|
|||||||