Compare commits

..

19 Commits

Author SHA1 Message Date
Owen 7506c69c3c Rename newt site to site 2026-09-08 17:30:22 -04:00
Owen eb133f6254 Update the install and run commands to use the cli 2026-09-08 17:23:49 -04:00
Owen 251f2dd1de Replace newt and olm with pangolin-cli 2026-09-08 16:47:31 -04:00
Owen 82c5dcf16f Fix tsconfig to use react-jsx 2026-09-08 16:45:56 -04:00
Owen 59f0c90836 Fix circular import 2026-09-08 16:42:22 -04:00
Owen b0e64a5e5a Widen subnet 2026-09-08 16:31:58 -04:00
Owen 733d3ece0e Quiet up error logs 2026-09-08 10:01:59 -04:00
Owen 59b228ce39 Quiet log message 2026-09-08 09:26:57 -04:00
Owen 080bcbaf97 Use endpoint instead of reachableAt for remote nodes 2026-09-07 11:55:45 -04:00
Owen ea9017ac06 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-04 17:15:33 -04:00
Owen 88770ff97b Refactor form submissions to use startTransition for improved performance 2026-09-04 17:15:20 -04:00
miloschwartz 8e75425887 update screenshots 2026-09-04 15:46:56 -04:00
Owen 44b0186044 Fix yaml import 2026-09-04 15:32:59 -04:00
Owen 063f6b5ca9 Basic DNS config send empty for olm 2026-09-04 12:31:04 -04:00
Owen 778a840ed7 Fix react bug not making it possible to complete security form 2026-09-04 10:08:57 -04:00
Owen 9d19195089 Configurable tab title and disable flag for keys 2026-09-04 09:20:20 -04:00
Owen 54bbe82504 Dont log invalid message type
Fixes #3695
2026-09-04 09:06:58 -04:00
miloschwartz de57df2520 update readme and screenshots 2026-09-03 16:17:07 -04:00
Owen 9e392a967d Add AI disclosure 2026-09-03 15:41:12 -04:00
46 changed files with 518 additions and 188 deletions
+8
View File
@@ -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
+16 -5
View File
@@ -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
+1 -1
View File
@@ -23,7 +23,7 @@ export const clearExitNodes: CommandModule<
// Delete all exit nodes // Delete all exit nodes
const deletedCount = await db const deletedCount = await db
.delete(exitNodes) .delete(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)).returning();; // delete all
console.log(`Deleted ${deletedCount.length} exit node(s) from the database`); console.log(`Deleted ${deletedCount.length} exit node(s) from the database`);
+1 -1
View File
@@ -5,7 +5,7 @@ import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/lib/sshCA"; import { generateCA } from "@server/lib/sshCA";
import fs from "fs"; import fs from "fs";
import yaml from "js-yaml"; import * as yaml from "js-yaml";
type GenerateOrgCaKeysArgs = { type GenerateOrgCaKeysArgs = {
orgId: string; orgId: string;
+1 -1
View File
@@ -4,7 +4,7 @@ import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import yaml from "js-yaml"; import * as yaml from "js-yaml";
type RotateServerSecretArgs = { type RotateServerSecretArgs = {
"old-secret": string; "old-secret": string;
+5 -3
View File
@@ -152,7 +152,7 @@
"siteResourcesHowToAccess": "How to access", "siteResourcesHowToAccess": "How to access",
"siteResourcesTargetsOnSite": "Targets on this site", "siteResourcesTargetsOnSite": "Targets on this site",
"siteSetting": "{siteName} Settings", "siteSetting": "{siteName} Settings",
"siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnel": "Pangolin Site (Recommended)",
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.",
"siteWg": "Basic WireGuard", "siteWg": "Basic WireGuard",
"siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
@@ -2898,7 +2898,7 @@
"editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
"createInternalResourceDialogNoSitesAvailable": "No Sites Available", "createInternalResourceDialogNoSitesAvailable": "No Sites Available",
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.", "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one site with a subnet configured to create private resources.",
"createInternalResourceDialogClose": "Close", "createInternalResourceDialogClose": "Close",
"createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResource": "Create Private Resource",
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
@@ -3767,6 +3767,7 @@
"noData": "No Data", "noData": "No Data",
"machineClients": "Machine Clients", "machineClients": "Machine Clients",
"install": "Install", "install": "Install",
"downloadInstaller": "Download Installer",
"run": "Run", "run": "Run",
"envFile": "Environment File", "envFile": "Environment File",
"serviceFile": "Service File", "serviceFile": "Service File",
@@ -4363,5 +4364,6 @@
"rdpUnicodeKeyboardMode": "Unicode keyboard mode", "rdpUnicodeKeyboardMode": "Unicode keyboard mode",
"sessionToolbarShow": "Show toolbar", "sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar", "sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals" "actionUpdateSiteApprovals": "Update Site Approvals",
"check": "Check"
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 800 KiB

+5
View File
@@ -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;
} }
+1 -1
View File
@@ -71,7 +71,7 @@ export async function withRetry<T>(
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`, `Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code } { code: error?.code ?? error?.cause?.code }
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
+3 -2
View File
@@ -348,8 +348,8 @@ export const configSchema = z
.optional() .optional()
.pipe(z.string()) .pipe(z.string())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
subnet_group: z.string().optional().default("100.89.137.0/20"), subnet_group: z.string().optional().default("100.89.137.0/18"),
block_size: z.number().positive().gt(0).optional().default(24), block_size: z.number().positive().gt(0).optional().default(22),
site_block_size: z site_block_size: z
.number() .number()
.positive() .positive()
@@ -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()
+6 -1
View File
@@ -2478,7 +2478,12 @@ hybridRouter.post(
destinations: destinations destinations: destinations
}); });
} catch (error) { } catch (error) {
logger.error(error); if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error);
}
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -0,0 +1,23 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { EventEmitter } from "events";
export interface ExitNodeOnlineEvent {
exitNodeId: number;
endpoint: string;
}
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
export const exitNodeEvents = new EventEmitter();
@@ -12,11 +12,27 @@
*/ */
import axios from "axios"; import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db"; import { db, newts, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import redisManager from "#private/lib/redis"; import redisManager from "#private/lib/redis";
// import { sendToClient } from "#private/routers/ws"; import { sendToClient } from "../ws";
import {
exitNodeEvents,
EXIT_NODE_ONLINE_EVENT,
ExitNodeOnlineEvent
} from "./exitNodeEvents";
exitNodeEvents.on(
EXIT_NODE_ONLINE_EVENT,
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
logger.error("Failed to schedule exit node reconnect", {
error
});
});
}
);
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect { interface PendingReconnect {
startTime: number; startTime: number;
reachableAt: string; endpoint: string;
} }
// In-memory tracking for this node // In-memory tracking for this node
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
*/ */
export async function scheduleExitNodeReconnect( export async function scheduleExitNodeReconnect(
exitNodeId: number, exitNodeId: number,
reachableAt: string endpoint: string
): Promise<void> { ): Promise<void> {
logger.info( logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})` `Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
); );
const entry: PendingReconnect = { const entry: PendingReconnect = {
startTime: Date.now(), startTime: Date.now(),
reachableAt endpoint
}; };
pendingReconnects.set(exitNodeId, entry); pendingReconnects.set(exitNodeId, entry);
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
); );
await redisManager.hset( await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`, `${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt", "endpoint",
reachableAt endpoint
); );
} }
} }
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
`${REDIS_HASH_PREFIX}${id}`, `${REDIS_HASH_PREFIX}${id}`,
"startTime" "startTime"
); );
const reachableAt = await redisManager.hget( const endpoint = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`, `${REDIS_HASH_PREFIX}${id}`,
"reachableAt" "endpoint"
); );
if (startTimeStr && reachableAt) { if (startTimeStr && endpoint) {
toProcess.set(id, { toProcess.set(id, {
startTime: parseInt(startTimeStr, 10), startTime: parseInt(startTimeStr, 10),
reachableAt endpoint
}); });
} }
} }
@@ -135,7 +151,7 @@ async function processPendingReconnects(): Promise<void> {
} }
// Check if the exit node HTTP endpoint is reachable // Check if the exit node HTTP endpoint is reachable
const pingUrl = `${entry.reachableAt}/ping`; const pingUrl = `http://${entry.endpoint}/ping`;
try { try {
await axios.get(pingUrl, { timeout: 5000 }); await axios.get(pingUrl, { timeout: 5000 });
} catch { } catch {
@@ -150,47 +166,47 @@ async function processPendingReconnects(): Promise<void> {
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.` `Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
); );
// await sendReconnectToNewts(exitNodeId); await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId); await removePending(exitNodeId);
} }
} }
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> { async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
// try { try {
// const connectedNewts = await db const connectedNewts = await db
// .select({ newtId: newts.newtId }) .select({ newtId: newts.newtId })
// .from(newts) .from(newts)
// .innerJoin(sites, eq(newts.siteId, sites.siteId)) .innerJoin(sites, eq(newts.siteId, sites.siteId))
// .where(eq(sites.exitNodeId, exitNodeId)); .where(eq(sites.exitNodeId, exitNodeId));
// if (connectedNewts.length === 0) { if (connectedNewts.length === 0) {
// logger.debug( logger.debug(
// `No newts found for exit node ${exitNodeId}, nothing to reconnect` `No newts found for exit node ${exitNodeId}, nothing to reconnect`
// ); );
// return; return;
// } }
// logger.info( logger.info(
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}` `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
// ); );
// const reconnectMessage = { const reconnectMessage = {
// type: "newt/wg/reconnect", type: "newt/wg/reconnect",
// data: {} data: {}
// }; };
// await Promise.allSettled( await Promise.allSettled(
// connectedNewts.map(({ newtId }) => connectedNewts.map(({ newtId }) =>
// sendToClient(newtId, reconnectMessage) sendToClient(newtId, reconnectMessage)
// ) )
// ); );
// } catch (error) { } catch (error) {
// logger.error( logger.error(
// `Failed to send reconnect messages for exit node ${exitNodeId}`, `Failed to send reconnect messages for exit node ${exitNodeId}`,
// { error } { error }
// ); );
// } }
// } }
async function removePending(exitNodeId: number): Promise<void> { async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId); pendingReconnects.delete(exitNodeId);
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db"; import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler"; import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
/** /**
* Handles ping messages from clients and responds with pong * Handles ping messages from clients and responds with pong
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
try { try {
// Fetch the current state before updating so we can detect the offline→online transition // Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db const [currentExitNode] = await db
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt }) .select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
.from(exitNodes) .from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1); .limit(1);
@@ -55,12 +55,14 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects // If the exit node was offline and is now coming online, schedule newt reconnects
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) { if (
scheduleExitNodeReconnect( currentExitNode &&
remoteExitNode.exitNodeId, !currentExitNode.online &&
currentExitNode.reachableAt currentExitNode.endpoint
).catch((error) => { ) {
logger.error("Failed to schedule exit node reconnect", { error }); exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
exitNodeId: remoteExitNode.exitNodeId,
endpoint: currentExitNode.endpoint
}); });
} }
} catch (error) { } catch (error) {
+2 -1
View File
@@ -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({
+20
View File
@@ -0,0 +1,20 @@
import config from "@server/lib/config";
// Mirrors the optional fields on the olm client's TunnelConfig - any field
// present here overrides the value the olm client is otherwise locally
// configured with; an absent field leaves the client's own config alone.
export type OlmDnsConfig = {
upstreamDns?: string[];
overrideDns?: boolean;
tunnelDns?: boolean;
matchDomains?: string[];
};
export function buildOlmDnsConfig(): OlmDnsConfig | undefined {
return {
upstreamDns: undefined,
overrideDns: undefined,
tunnelDns: undefined,
matchDomains: undefined
};
}
@@ -15,6 +15,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { buildOlmDnsConfig } from "./dnsConfig";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -512,6 +513,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
} }
: undefined, : undefined,
dnsConfig: buildOlmDnsConfig(),
chainId: chainId chainId: chainId
} }
}, },
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (!resources || resources.length === 0) { if (!resources || resources.length === 0) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource not found` `handleOlmServerInitAddPeerHandshake: Resource not found`
); );
await sendCancel(); await sendCancel();
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (resources.length > 1) { if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
); );
return; return;
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (currentResourceAssociationCaches.length === 0) { if (currentResourceAssociationCaches.length === 0) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` `handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
); );
await sendCancel(); await sendCancel();
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (!resource.networkId) { if (!resource.networkId) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` `handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
); );
await sendCancel(); await sendCancel();
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(siteNetworks.networkId, resource.networkId)); .where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) { if (!siteRows || siteRows.length === 0) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}` `handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
); );
await sendCancel(); await sendCancel();
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (sitesToProcess.length === 0) { if (sitesToProcess.length === 0) {
logger.error( logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel(); await sendCancel();
return; return;
} }
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping` `handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
); );
continue; continue;
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, site.exitNodeId)); .where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) { if (!exitNode) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping` `handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
); );
continue; continue;
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
} }
if (!handshakeInitiated) { if (!handshakeInitiated) {
logger.error( logger.warn(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` `handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
); );
await sendCancel(); await sendCancel();
+4 -1
View File
@@ -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({
+6 -1
View File
@@ -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>
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
subscriptionStatus = subRes.data.data; subscriptionStatus = subRes.data.data;
} catch (error) { } catch (error) {
// If subscription fetch fails, keep subscriptionStatus as null // If subscription fetch fails, keep subscriptionStatus as null
console.error("Failed to fetch subscription status:", error); // console.error("Failed to fetch subscription status:", error);
} }
} }
+1
View File
@@ -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
@@ -13,7 +13,12 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useRef, useState } from "react"; import {
useActionState,
useRef,
useState,
startTransition
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -518,9 +523,12 @@ export default function Page() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...internalForm}> <Form {...internalForm}>
<form <form
action={ onSubmit={(e) => {
submitInternalAction e.preventDefault();
} startTransition(() => {
submitInternalAction();
});
}}
className="space-y-4" className="space-y-4"
id="create-user-form" id="create-user-form"
> >
+12 -2
View File
@@ -3,7 +3,12 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useState, useTransition, useActionState } from "react"; import {
useState,
useTransition,
useActionState,
startTransition
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -243,7 +248,12 @@ function GeneralSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
className="grid gap-4" className="grid gap-4"
id="org-general-settings-form" id="org-general-settings-form"
> >
@@ -3,7 +3,13 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useState, useRef, useActionState, type ComponentRef } from "react"; import {
useState,
useRef,
useActionState,
startTransition,
type ComponentRef
} from "react";
import { import {
Form, Form,
FormControl, FormControl,
@@ -117,6 +123,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 +180,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 +211,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
className="grid gap-4" className="grid gap-4"
id="org-log-retention-settings-form" id="org-log-retention-settings-form"
> >
@@ -827,6 +844,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 +917,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 +965,12 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
ref={formRef} ref={formRef}
id="security-settings-section-form" id="security-settings-section-form"
className="space-y-4" className="space-y-4"
@@ -41,7 +41,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo } from "react"; import { useActionState, useEffect, useMemo, startTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -232,7 +232,12 @@ export default function PrivateResourceInferencePage() {
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
id="private-resource-providers-form" id="private-resource-providers-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
@@ -29,7 +29,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo, startTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
@@ -97,7 +97,12 @@ export default function PrivateResourceGeneralPage() {
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
id="private-resource-general-form" id="private-resource-general-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
@@ -41,7 +41,13 @@ import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { toASCII, toUnicode } from "punycode"; import { toASCII, toUnicode } from "punycode";
import { useActionState, useEffect, useMemo, useState } from "react"; import {
useActionState,
useEffect,
useMemo,
useState,
startTransition
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod"; import z from "zod";
@@ -282,7 +288,12 @@ export default function GeneralForm() {
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
id="general-settings-form" id="general-settings-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react"; import { useActionState, useState, startTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -209,7 +209,15 @@ function ProxyResourceHttpForm({
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...form}> <Form {...form}>
<form action={formAction} id="http-settings-form"> <form
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
id="http-settings-form"
>
<SettingsFormGrid> <SettingsFormGrid>
{!env.flags.usePangolinDns && ( {!env.flags.usePangolinDns && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
@@ -37,7 +37,7 @@ import { AxiosResponse } from "axios";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect, startTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod"; import z from "zod";
@@ -203,7 +203,12 @@ export default function ResourceMaintenancePage() {
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...maintenanceForm}> <Form {...maintenanceForm}>
<form <form
action={maintenanceFormAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
maintenanceFormAction();
});
}}
id="maintenance-settings-form" id="maintenance-settings-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
@@ -48,7 +48,8 @@ import { useRouter } from "next/navigation";
import { import {
use, use,
useActionState, useActionState,
useMemo useMemo,
startTransition
} from "react"; } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -206,7 +207,12 @@ function ProxyResourceProtocolForm({
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<Form {...proxySettingsForm}> <Form {...proxySettingsForm}>
<form <form
action={formAction} onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
id="proxy-protocol-settings-form" id="proxy-protocol-settings-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
@@ -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";
+10 -6
View File
@@ -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 = (
+5 -2
View File
@@ -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 (
+2 -1
View File
@@ -62,7 +62,8 @@ export function OrgLabelForm({
<form <form
id="org-label-form" id="org-label-form"
className="flex flex-col gap-4 px-0.5" className="flex flex-col gap-4 px-0.5"
action={async () => { onSubmit={async (e) => {
e.preventDefault();
if (await form.trigger()) { if (await form.trigger()) {
onSubmit(form.getValues()); onSubmit(form.getValues());
} }
@@ -211,7 +211,15 @@ export default function AlertRuleGraphEditor({
return ( return (
<Form {...form}> <Form {...form}>
<form id={FORM_ID} action={formAction}> <form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
formAction();
});
}}
>
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} /> <PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start"> <div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
+112 -47
View File
@@ -8,6 +8,7 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } from "./Settings";
import { CheckboxWithLabel } from "./ui/checkbox"; import { CheckboxWithLabel } from "./ui/checkbox";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react"; import { useState } from "react";
import { import {
@@ -18,11 +19,14 @@ import {
FaLinux, FaLinux,
FaWindows FaWindows
} from "react-icons/fa"; } from "react-icons/fa";
import { ExternalLink } from "lucide-react"; import { Download, ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si"; import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem = string | { title: string; command: string }; export type CommandItem =
| string
| { title: string; command: string }
| { title: string; link: string };
const PLATFORMS = [ const PLATFORMS = [
"linux", "linux",
@@ -41,14 +45,12 @@ export type NewtSiteInstallCommandsProps = {
id: string; id: string;
secret: string; secret: string;
endpoint: string; endpoint: string;
version?: string;
}; };
export function NewtSiteInstallCommands({ export function NewtSiteInstallCommands({
id, id,
secret, secret,
endpoint, endpoint
version = "latest"
}: NewtSiteInstallCommandsProps) { }: NewtSiteInstallCommandsProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -88,25 +90,39 @@ export function NewtSiteInstallCommands({
Run: [ Run: [
{ {
title: t("install"), title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash` command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
}, },
{ {
title: t("run"), title: t("run"),
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
} }
], ],
"Systemd Service": [ "Auto Systemd Service": [
{ {
title: t("install"), title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash` command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `sudo pangolin service status site`
}
],
"Manual Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
}, },
{ {
title: t("envFile"), title: t("envFile"),
command: `# Create the directory and environment file command: `# Create the directory and environment file
sudo install -d -m 0755 /etc/newt sudo install -d -m 0755 /etc/pangolin
sudo tee /etc/newt/newt.env > /dev/null << 'EOF' sudo tee /etc/pangolin/pangolin-site.env > /dev/null << 'EOF'
NEWT_ID=${id} SITE_ID=${id}
NEWT_SECRET=${secret} SITE_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${ PANGOLIN_ENDPOINT=${endpoint}${
!acceptClients !acceptClients
? ` ? `
@@ -119,11 +135,11 @@ DISABLE_SSH=true`
: "" : ""
} }
EOF EOF
sudo chmod 600 /etc/newt/newt.env` sudo chmod 600 /etc/pangolin/pangolin-site.env`
}, },
{ {
title: t("serviceFile"), title: t("serviceFile"),
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF' command: `sudo tee /etc/systemd/system/pangolin-site.service > /dev/null << 'EOF'
[Unit] [Unit]
Description=Newt Description=Newt
Wants=network-online.target Wants=network-online.target
@@ -133,8 +149,8 @@ After=network-online.target
Type=simple Type=simple
User=root User=root
Group=root Group=root
EnvironmentFile=/etc/newt/newt.env EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/usr/local/bin/newt ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
Restart=always Restart=always
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077
@@ -148,7 +164,7 @@ EOF`
{ {
title: t("enableAndStart"), title: t("enableAndStart"),
command: `sudo systemctl daemon-reload command: `sudo systemctl daemon-reload
sudo systemctl enable --now newt` sudo systemctl enable --now pangolin-site`
} }
] ]
}, },
@@ -156,23 +172,51 @@ sudo systemctl enable --now newt`
Run: [ Run: [
{ {
title: t("install"), title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash` command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
}, },
{ {
title: t("run"), title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
Service: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `sudo pangolin service status site`
} }
] ]
}, },
windows: { windows: {
x64: [ Run: [
{ {
title: t("install"), title: t("install"),
command: `curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"` link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
}, },
{ {
title: t("run"), title: t("run"),
command: `newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
Service: [
{
title: t("install"),
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `pangolin service status site`
} }
] ]
}, },
@@ -180,19 +224,20 @@ sudo systemctl enable --now newt`
"Docker Compose": [ "Docker Compose": [
`services: `services:
newt: newt:
image: fosrl/newt image: fosrl/pangolin-cli
container_name: newt container_name: newt
restart: unless-stopped restart: unless-stopped
environment: environment:
- PANGOLIN_ENDPOINT=${endpoint} - PANGOLIN_ENDPOINT=${endpoint}
- NEWT_ID=${id} - SITE_ID=${id}
- NEWT_SECRET=${secret}${acceptClientsEnv}` - SITE_SECRET=${secret}${acceptClientsEnv}`
], ],
"Docker Run": [ "Docker Run": [
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` `docker run -dit --network host fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
] ]
}, },
kubernetes: { kubernetes: {
// we are leaving this using newt until we change it to use the cli
"Helm Chart": [ "Helm Chart": [
`helm repo add fossorial https://charts.fossorial.io`, `helm repo add fossorial https://charts.fossorial.io`,
`helm repo update fossorial`, `helm repo update fossorial`,
@@ -216,15 +261,15 @@ sudo systemctl enable --now newt`
podman: { podman: {
"Podman Quadlet": [ "Podman Quadlet": [
`[Unit] `[Unit]
Description=Newt container Description=Pangolin Site Container
[Container] [Container]
ContainerName=newt ContainerName=pangolin-site
Image=docker.io/fosrl/newt Image=docker.io/fosrl/pangolin-cli
Environment=PANGOLIN_ENDPOINT=${endpoint} Environment=PANGOLIN_ENDPOINT=${endpoint}
Environment=NEWT_ID=${id} Environment=SITE_ID=${id}
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""} Environment=SITE_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
# Secret=newt-secret,type=env,target=NEWT_SECRET # Secret=pangolin-secret,type=env,target=SITE_SECRET
[Service] [Service]
Restart=always Restart=always
@@ -233,12 +278,12 @@ Restart=always
WantedBy=default.target` WantedBy=default.target`
], ],
"Podman Run": [ "Podman Run": [
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` `podman run -dit docker.io/fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
] ]
}, },
nixos: { nixos: {
Flake: [ Flake: [
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` `${runAsRootPrefix}nix run 'nixpkgs#pangolin-cli' -- up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
] ]
} }
}; };
@@ -277,9 +322,7 @@ WantedBy=default.target`
/> />
<OptionSelect<string> <OptionSelect<string>
label={ label={t("method")}
platform === "windows" ? t("architecture") : t("method")
}
options={getArchitectures(platform).map((arch) => ({ options={getArchitectures(platform).map((arch) => ({
value: arch, value: arch,
label: arch label: arch
@@ -377,14 +420,23 @@ WantedBy=default.target`
)} )}
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{commands.map((item, index) => { {commands.map((item, index) => {
const isLink =
typeof item !== "string" && "link" in item;
const commandText = const commandText =
typeof item === "string" ? item : item.command; typeof item === "string"
? item
: isLink
? undefined
: item.command;
const linkHref = isLink
? (item as { link: string }).link
: undefined;
const title = const title =
typeof item === "string" typeof item === "string"
? undefined ? undefined
: item.title; : item.title;
const key = `${title ?? ""}::${commandText}`; const key = `${title ?? ""}::${commandText ?? linkHref}`;
return ( return (
<div key={key}> <div key={key}>
@@ -393,10 +445,23 @@ WantedBy=default.target`
{title} {title}
</p> </p>
)} )}
<CopyTextBox {isLink ? (
text={commandText} <Button
outline={true} asChild
/> variant="outline"
className="w-full"
>
<a href={linkHref}>
<Download className="h-4 w-4 mr-2" />
{t("downloadInstaller")}
</a>
</Button>
) : (
<CopyTextBox
text={commandText!}
outline={true}
/>
)}
</div> </div>
); );
})} })}
@@ -456,11 +521,11 @@ function getPlatformName(platformName: Platform) {
function getArchitectures(platform: Platform) { function getArchitectures(platform: Platform) {
switch (platform) { switch (platform) {
case "linux": case "linux":
return ["Run", "Systemd Service"]; return ["Run", "Auto Systemd Service", "Manual Systemd Service"];
case "macos": case "macos":
return ["Run"]; return ["Run", "Service"];
case "windows": case "windows":
return ["x64"]; return ["Run", "Service"];
case "docker": case "docker":
return ["Docker Compose", "Docker Run"]; return ["Docker Compose", "Docker Run"];
case "kubernetes": case "kubernetes":
+71 -22
View File
@@ -1,4 +1,4 @@
import { Terminal } from "lucide-react"; import { Download, Terminal } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { FaDocker, FaWindows } from "react-icons/fa"; import { FaDocker, FaWindows } from "react-icons/fa";
@@ -10,9 +10,13 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } from "./Settings";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
export type CommandItem = string | { title: string; command: string }; export type CommandItem =
| string
| { title: string; command: string }
| { title: string; link: string };
const PLATFORMS = ["unix", "docker", "windows"] as const; const PLATFORMS = ["unix", "docker", "windows"] as const;
@@ -22,14 +26,12 @@ export type OlmInstallCommandsProps = {
id: string; id: string;
secret: string; secret: string;
endpoint: string; endpoint: string;
version?: string;
}; };
export function OlmInstallCommands({ export function OlmInstallCommands({
id, id,
secret, secret,
endpoint, endpoint
version = "latest"
}: OlmInstallCommandsProps) { }: OlmInstallCommandsProps) {
const t = useTranslations(); const t = useTranslations();
@@ -40,14 +42,28 @@ export function OlmInstallCommands({
const commandList: Record<Platform, Record<string, CommandItem[]>> = { const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: { unix: {
All: [ Run: [
{ {
title: t("install"), title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash` command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
}, },
{ {
title: t("run"), title: t("run"),
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach` command: `sudo pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
}
],
Service: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
},
{
title: t("check"),
command: `sudo pangolin service status client`
} }
] ]
}, },
@@ -73,15 +89,28 @@ export function OlmInstallCommands({
] ]
}, },
windows: { windows: {
x64: [ Run: [
{ {
title: t("install"), title: t("install"),
command: `# Download and run the installer to install Olm first\n link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
}, },
{ {
title: t("run"), title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` command: `pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
],
Service: [
{
title: t("install"),
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
},
{
title: t("check"),
command: `pangolin service status client`
} }
] ]
} }
@@ -121,9 +150,7 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
/> />
<OptionSelect<string> <OptionSelect<string>
label={ label={t("method")}
platform === "docker" ? t("method") : t("architecture")
}
options={getArchitectures(platform).map((arch) => ({ options={getArchitectures(platform).map((arch) => ({
value: arch, value: arch,
label: arch label: arch
@@ -138,8 +165,17 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
<p className="font-semibold mb-3">{t("commands")}</p> <p className="font-semibold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3"> <div className="mt-2 space-y-3">
{commands.map((item, index) => { {commands.map((item, index) => {
const isLink =
typeof item !== "string" && "link" in item;
const commandText = const commandText =
typeof item === "string" ? item : item.command; typeof item === "string"
? item
: isLink
? undefined
: item.command;
const linkHref = isLink
? (item as { link: string }).link
: undefined;
const title = const title =
typeof item === "string" typeof item === "string"
? undefined ? undefined
@@ -152,10 +188,23 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
{title} {title}
</p> </p>
)} )}
<CopyTextBox {isLink ? (
text={commandText} <Button
outline={true} asChild
/> variant="outline"
className="w-full"
>
<a href={linkHref}>
<Download className="h-4 w-4 mr-2" />
{t("downloadInstaller")}
</a>
</Button>
) : (
<CopyTextBox
text={commandText!}
outline={true}
/>
)}
</div> </div>
); );
})} })}
@@ -169,13 +218,13 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
function getArchitectures(platform: Platform) { function getArchitectures(platform: Platform) {
switch (platform) { switch (platform) {
case "unix": case "unix":
return ["All"]; return ["Run", "Service"];
case "windows": case "windows":
return ["x64"]; return ["Run", "Service"];
case "docker": case "docker":
return ["Docker Compose", "Docker Run"]; return ["Docker Compose", "Docker Run"];
default: default:
return ["x64"]; return ["Run"];
} }
} }
+4
View File
@@ -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
}, },
+1
View File
@@ -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;
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"paths": { "paths": {
"@server/*": [ "@server/*": [