Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7506c69c3c | |||
| eb133f6254 | |||
| 251f2dd1de | |||
| 82c5dcf16f | |||
| 59f0c90836 | |||
| b0e64a5e5a | |||
| 733d3ece0e | |||
| 59b228ce39 | |||
| 080bcbaf97 | |||
| ea9017ac06 | |||
| 88770ff97b | |||
| 8e75425887 | |||
| 44b0186044 | |||
| 063f6b5ca9 | |||
| 778a840ed7 | |||
| 9d19195089 | |||
| 54bbe82504 | |||
| de57df2520 | |||
| 9e392a967d |
@@ -34,6 +34,14 @@ body:
|
||||
validations:
|
||||
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
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
|
||||
@@ -37,11 +37,22 @@
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
</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
|
||||
|
||||
@@ -53,9 +64,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
||||
|
||||
## Deployment Options
|
||||
|
||||
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
|
||||
- **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.
|
||||
- **Pangolin Cloud** - Fully managed service with no infrastructure required.
|
||||
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const clearExitNodes: CommandModule<
|
||||
// Delete all exit nodes
|
||||
const deletedCount = await db
|
||||
.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`);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { encrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
type GenerateOrgCaKeysArgs = {
|
||||
orgId: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
type RotateServerSecretArgs = {
|
||||
"old-secret": string;
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"siteResourcesHowToAccess": "How to access",
|
||||
"siteResourcesTargetsOnSite": "Targets on this site",
|
||||
"siteSetting": "{siteName} Settings",
|
||||
"siteNewtTunnel": "Newt Site (Recommended)",
|
||||
"siteNewtTunnel": "Pangolin Site (Recommended)",
|
||||
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.",
|
||||
"siteWg": "Basic WireGuard",
|
||||
"siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
|
||||
@@ -2898,7 +2898,7 @@
|
||||
"editInternalResourceDialogAlias": "Alias",
|
||||
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||
"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",
|
||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||
@@ -3767,6 +3767,7 @@
|
||||
"noData": "No Data",
|
||||
"machineClients": "Machine Clients",
|
||||
"install": "Install",
|
||||
"downloadInstaller": "Download Installer",
|
||||
"run": "Run",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
@@ -4363,5 +4364,6 @@
|
||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||
"check": "Check"
|
||||
}
|
||||
|
||||
|
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"
|
||||
: "false";
|
||||
|
||||
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI = parsedConfig.flags
|
||||
?.disable_virtual_api_keys_ui
|
||||
? "true"
|
||||
: "false";
|
||||
|
||||
this.rawConfig = parsedConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
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 }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
@@ -348,8 +348,8 @@ export const configSchema = z
|
||||
.optional()
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/18"),
|
||||
block_size: z.number().positive().gt(0).optional().default(22),
|
||||
site_block_size: z
|
||||
.number()
|
||||
.positive()
|
||||
@@ -442,6 +442,7 @@ export const configSchema = z
|
||||
disable_config_managed_domains: z.boolean().optional(),
|
||||
disable_product_help_banners: 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),
|
||||
disable_private_http_placeholder: z
|
||||
.boolean()
|
||||
|
||||
@@ -2478,7 +2478,12 @@ hybridRouter.post(
|
||||
destinations: destinations
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (!(
|
||||
error instanceof Error &&
|
||||
error.message === "Exit node not allowed"
|
||||
)) {
|
||||
logger.error(error);
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
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 { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
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 CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
reachableAt: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
reachableAt: string
|
||||
endpoint: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
reachableAt
|
||||
endpoint
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
"endpoint",
|
||||
endpoint
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const reachableAt = await redisManager.hget(
|
||||
const endpoint = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"reachableAt"
|
||||
"endpoint"
|
||||
);
|
||||
if (startTimeStr && reachableAt) {
|
||||
if (startTimeStr && endpoint) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
reachableAt
|
||||
endpoint
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -135,7 +151,7 @@ async function processPendingReconnects(): Promise<void> {
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
const pingUrl = `http://${entry.endpoint}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
@@ -150,47 +166,47 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
// await sendReconnectToNewts(exitNodeId);
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
// try {
|
||||
// const connectedNewts = await db
|
||||
// .select({ newtId: newts.newtId })
|
||||
// .from(newts)
|
||||
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
// .where(eq(sites.exitNodeId, exitNodeId));
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
// if (connectedNewts.length === 0) {
|
||||
// logger.debug(
|
||||
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// logger.info(
|
||||
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
// );
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
|
||||
// const reconnectMessage = {
|
||||
// type: "newt/wg/reconnect",
|
||||
// data: {}
|
||||
// };
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
|
||||
// await Promise.allSettled(
|
||||
// connectedNewts.map(({ newtId }) =>
|
||||
// sendToClient(newtId, reconnectMessage)
|
||||
// )
|
||||
// );
|
||||
// } catch (error) {
|
||||
// logger.error(
|
||||
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
// { error }
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
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
|
||||
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
try {
|
||||
// Fetch the current state before updating so we can detect the offline→online transition
|
||||
const [currentExitNode] = await db
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
@@ -55,12 +55,14 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||
|
||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
if (
|
||||
currentExitNode &&
|
||||
!currentExitNode.online &&
|
||||
currentExitNode.endpoint
|
||||
) {
|
||||
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
||||
exitNodeId: remoteExitNode.exitNodeId,
|
||||
endpoint: currentExitNode.endpoint
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -104,7 +104,8 @@ const processMessage = async (
|
||||
|
||||
const handler = messageHandlers[message.type];
|
||||
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({
|
||||
|
||||
@@ -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 { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { buildOlmDnsConfig } from "./dnsConfig";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
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
|
||||
}
|
||||
: undefined,
|
||||
dnsConfig: buildOlmDnsConfig(),
|
||||
chainId: chainId
|
||||
}
|
||||
},
|
||||
|
||||
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
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
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!resource.networkId) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||
|
||||
if (!siteRows || siteRows.length === 0) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!handshakeInitiated) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
|
||||
@@ -353,7 +353,10 @@ const setupConnection = async (
|
||||
|
||||
const handler = messageHandlers[message.type];
|
||||
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({
|
||||
|
||||
@@ -76,6 +76,11 @@ export default async function KeysPage(props: KeysPageProps) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const env = pullEnv();
|
||||
if (env.flags.disableVirtualApiKeysUi) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
let keysData: ListMyVirtualApiKeysResponse | null = null;
|
||||
try {
|
||||
const res = await internal.get<
|
||||
@@ -90,7 +95,6 @@ export default async function KeysPage(props: KeysPageProps) {
|
||||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const env = pullEnv();
|
||||
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
|
||||
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
|
||||
|
||||
@@ -106,6 +110,7 @@ export default async function KeysPage(props: KeysPageProps) {
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
env={env}
|
||||
>
|
||||
<UserVirtualApiKeys orgId={orgId} initialData={keysData} />
|
||||
</Layout>
|
||||
|
||||
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
|
||||
subscriptionStatus = subRes.data.data;
|
||||
} catch (error) {
|
||||
// If subscription fetch fails, keep subscriptionStatus as null
|
||||
console.error("Failed to fetch subscription status:", error);
|
||||
// console.error("Failed to fetch subscription status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||
showSidebar={false}
|
||||
launcherMode
|
||||
showViewAsAdmin={isAdminOrOwner}
|
||||
env={env}
|
||||
>
|
||||
{overview && launcherData ? (
|
||||
<ResourceLauncher
|
||||
|
||||
@@ -13,7 +13,12 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState, useRef, useState } from "react";
|
||||
import {
|
||||
useActionState,
|
||||
useRef,
|
||||
useState,
|
||||
startTransition
|
||||
} from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -518,9 +523,12 @@ export default function Page() {
|
||||
<SettingsSectionForm>
|
||||
<Form {...internalForm}>
|
||||
<form
|
||||
action={
|
||||
submitInternalAction
|
||||
}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
submitInternalAction();
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,12 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useTransition, useActionState } from "react";
|
||||
import {
|
||||
useState,
|
||||
useTransition,
|
||||
useActionState,
|
||||
startTransition
|
||||
} from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -243,7 +248,12 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
className="grid gap-4"
|
||||
id="org-general-settings-form"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,13 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useActionState, type ComponentRef } from "react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useActionState,
|
||||
startTransition,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -117,6 +123,7 @@ export default function SecurityPage() {
|
||||
}
|
||||
|
||||
function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
const { updateOrg } = useOrgContext();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
SecurityFormSchema.pick({
|
||||
@@ -173,6 +180,11 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
// Update organization
|
||||
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({
|
||||
title: t("orgUpdated"),
|
||||
description: t("orgUpdatedDescription")
|
||||
@@ -199,7 +211,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
className="grid gap-4"
|
||||
id="org-log-retention-settings-form"
|
||||
>
|
||||
@@ -827,6 +844,7 @@ function LogRetentionSectionForm({ org }: SectionFormProps) {
|
||||
|
||||
function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
||||
const router = useRouter();
|
||||
const { updateOrg } = useOrgContext();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
SecurityFormSchema.pick({
|
||||
@@ -899,6 +917,11 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
||||
// Update organization
|
||||
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({
|
||||
title: t("orgUpdated"),
|
||||
description: t("orgUpdatedDescription")
|
||||
@@ -942,7 +965,12 @@ function SecuritySettingsSectionForm({ org }: SectionFormProps) {
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
ref={formRef}
|
||||
id="security-settings-section-form"
|
||||
className="space-y-4"
|
||||
|
||||
@@ -41,7 +41,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 { z } from "zod";
|
||||
|
||||
@@ -232,7 +232,12 @@ export default function PrivateResourceInferencePage() {
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
id="private-resource-providers-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useActionState, useMemo, startTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
@@ -97,7 +97,12 @@ export default function PrivateResourceGeneralPage() {
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
id="private-resource-general-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
|
||||
@@ -41,7 +41,13 @@ import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import z from "zod";
|
||||
@@ -282,7 +288,12 @@ export default function GeneralForm() {
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
id="general-settings-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useActionState, useState, startTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -209,7 +209,15 @@ function ProxyResourceHttpForm({
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form action={formAction} id="http-settings-form">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
id="http-settings-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
{!env.flags.usePangolinDns && (
|
||||
<SettingsFormCell span="full">
|
||||
|
||||
@@ -37,7 +37,7 @@ import { AxiosResponse } from "axios";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useActionState, useEffect, startTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import z from "zod";
|
||||
@@ -203,7 +203,12 @@ export default function ResourceMaintenancePage() {
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...maintenanceForm}>
|
||||
<form
|
||||
action={maintenanceFormAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
maintenanceFormAction();
|
||||
});
|
||||
}}
|
||||
id="maintenance-settings-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
|
||||
@@ -48,7 +48,8 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
use,
|
||||
useActionState,
|
||||
useMemo
|
||||
useMemo,
|
||||
startTransition
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -206,7 +207,12 @@ function ProxyResourceProtocolForm({
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...proxySettingsForm}>
|
||||
<form
|
||||
action={formAction}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
id="proxy-protocol-settings-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
|
||||
@@ -30,9 +30,15 @@ import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Resource Access"
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const env = pullEnv();
|
||||
const title =
|
||||
env.branding.resourceAuthPage?.titleText ||
|
||||
env.branding.appName ||
|
||||
"Resource Access";
|
||||
|
||||
return { title };
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -53,17 +53,21 @@ export type OrgNavSectionsOptions = {
|
||||
};
|
||||
|
||||
// Merged from 'user-management-and-resources' branch
|
||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||
export const orgLangingNavItems = (env?: Env): SidebarNavItem[] => [
|
||||
{
|
||||
title: "sidebarAccount",
|
||||
href: "/{orgId}",
|
||||
icon: <LayoutGrid className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarMyApiKeys",
|
||||
href: "/{orgId}/keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
...(!env?.flags.disableVirtualApiKeysUi
|
||||
? [
|
||||
{
|
||||
title: "sidebarMyApiKeys",
|
||||
href: "/{orgId}/keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
|
||||
export const orgNavSections = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import {
|
||||
orgLangingNavItems,
|
||||
type CommandBarNavSection,
|
||||
@@ -25,6 +26,7 @@ interface LayoutProps {
|
||||
defaultSidebarCollapsed?: boolean;
|
||||
launcherMode?: boolean;
|
||||
showViewAsAdmin?: boolean;
|
||||
env?: Env;
|
||||
}
|
||||
|
||||
export async function Layout({
|
||||
@@ -38,7 +40,8 @@ export async function Layout({
|
||||
showTopBar = true,
|
||||
defaultSidebarCollapsed = false,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
showViewAsAdmin = false,
|
||||
env
|
||||
}: LayoutProps) {
|
||||
const allCookies = await cookies();
|
||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||
@@ -49,7 +52,7 @@ export async function Layout({
|
||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||
|
||||
const launcherNavItems: SidebarNavItem[] = launcherMode
|
||||
? orgLangingNavItems
|
||||
? orgLangingNavItems(env)
|
||||
: [];
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,7 +62,8 @@ export function OrgLabelForm({
|
||||
<form
|
||||
id="org-label-form"
|
||||
className="flex flex-col gap-4 px-0.5"
|
||||
action={async () => {
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await form.trigger()) {
|
||||
onSubmit(form.getValues());
|
||||
}
|
||||
|
||||
@@ -211,7 +211,15 @@ export default function AlertRuleGraphEditor({
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} action={formAction}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
formAction();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { Button } from "./ui/button";
|
||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
@@ -18,11 +19,14 @@ import {
|
||||
FaLinux,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
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 = [
|
||||
"linux",
|
||||
@@ -41,14 +45,12 @@ export type NewtSiteInstallCommandsProps = {
|
||||
id: string;
|
||||
secret: string;
|
||||
endpoint: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export function NewtSiteInstallCommands({
|
||||
id,
|
||||
secret,
|
||||
endpoint,
|
||||
version = "latest"
|
||||
endpoint
|
||||
}: NewtSiteInstallCommandsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
@@ -88,25 +90,39 @@ export function NewtSiteInstallCommands({
|
||||
Run: [
|
||||
{
|
||||
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: `${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"),
|
||||
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"),
|
||||
command: `# Create the directory and environment file
|
||||
sudo install -d -m 0755 /etc/newt
|
||||
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
|
||||
NEWT_ID=${id}
|
||||
NEWT_SECRET=${secret}
|
||||
sudo install -d -m 0755 /etc/pangolin
|
||||
sudo tee /etc/pangolin/pangolin-site.env > /dev/null << 'EOF'
|
||||
SITE_ID=${id}
|
||||
SITE_SECRET=${secret}
|
||||
PANGOLIN_ENDPOINT=${endpoint}${
|
||||
!acceptClients
|
||||
? `
|
||||
@@ -119,11 +135,11 @@ DISABLE_SSH=true`
|
||||
: ""
|
||||
}
|
||||
EOF
|
||||
sudo chmod 600 /etc/newt/newt.env`
|
||||
sudo chmod 600 /etc/pangolin/pangolin-site.env`
|
||||
},
|
||||
{
|
||||
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]
|
||||
Description=Newt
|
||||
Wants=network-online.target
|
||||
@@ -133,8 +149,8 @@ After=network-online.target
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
EnvironmentFile=/etc/newt/newt.env
|
||||
ExecStart=/usr/local/bin/newt
|
||||
EnvironmentFile=/etc/pangolin/pangolin-site.env
|
||||
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
UMask=0077
|
||||
@@ -148,7 +164,7 @@ EOF`
|
||||
{
|
||||
title: t("enableAndStart"),
|
||||
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: [
|
||||
{
|
||||
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: `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: {
|
||||
x64: [
|
||||
Run: [
|
||||
{
|
||||
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"),
|
||||
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": [
|
||||
`services:
|
||||
newt:
|
||||
image: fosrl/newt
|
||||
image: fosrl/pangolin-cli
|
||||
container_name: newt
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- NEWT_ID=${id}
|
||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
||||
- SITE_ID=${id}
|
||||
- SITE_SECRET=${secret}${acceptClientsEnv}`
|
||||
],
|
||||
"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: {
|
||||
// we are leaving this using newt until we change it to use the cli
|
||||
"Helm Chart": [
|
||||
`helm repo add fossorial https://charts.fossorial.io`,
|
||||
`helm repo update fossorial`,
|
||||
@@ -216,15 +261,15 @@ sudo systemctl enable --now newt`
|
||||
podman: {
|
||||
"Podman Quadlet": [
|
||||
`[Unit]
|
||||
Description=Newt container
|
||||
Description=Pangolin Site Container
|
||||
|
||||
[Container]
|
||||
ContainerName=newt
|
||||
Image=docker.io/fosrl/newt
|
||||
ContainerName=pangolin-site
|
||||
Image=docker.io/fosrl/pangolin-cli
|
||||
Environment=PANGOLIN_ENDPOINT=${endpoint}
|
||||
Environment=NEWT_ID=${id}
|
||||
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
|
||||
# Secret=newt-secret,type=env,target=NEWT_SECRET
|
||||
Environment=SITE_ID=${id}
|
||||
Environment=SITE_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
|
||||
# Secret=pangolin-secret,type=env,target=SITE_SECRET
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
@@ -233,12 +278,12 @@ Restart=always
|
||||
WantedBy=default.target`
|
||||
],
|
||||
"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: {
|
||||
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>
|
||||
label={
|
||||
platform === "windows" ? t("architecture") : t("method")
|
||||
}
|
||||
label={t("method")}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
label: arch
|
||||
@@ -377,14 +420,23 @@ WantedBy=default.target`
|
||||
)}
|
||||
<div className="mt-2 space-y-3">
|
||||
{commands.map((item, index) => {
|
||||
const isLink =
|
||||
typeof item !== "string" && "link" in item;
|
||||
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 =
|
||||
typeof item === "string"
|
||||
? undefined
|
||||
: item.title;
|
||||
|
||||
const key = `${title ?? ""}::${commandText}`;
|
||||
const key = `${title ?? ""}::${commandText ?? linkHref}`;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
@@ -393,10 +445,23 @@ WantedBy=default.target`
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<CopyTextBox
|
||||
text={commandText}
|
||||
outline={true}
|
||||
/>
|
||||
{isLink ? (
|
||||
<Button
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -456,11 +521,11 @@ function getPlatformName(platformName: Platform) {
|
||||
function getArchitectures(platform: Platform) {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["Run", "Systemd Service"];
|
||||
return ["Run", "Auto Systemd Service", "Manual Systemd Service"];
|
||||
case "macos":
|
||||
return ["Run"];
|
||||
return ["Run", "Service"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
return ["Run", "Service"];
|
||||
case "docker":
|
||||
return ["Docker Compose", "Docker Run"];
|
||||
case "kubernetes":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { Download, Terminal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { FaDocker, FaWindows } from "react-icons/fa";
|
||||
@@ -10,9 +10,13 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "./Settings";
|
||||
import { Button } from "./ui/button";
|
||||
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;
|
||||
|
||||
@@ -22,14 +26,12 @@ export type OlmInstallCommandsProps = {
|
||||
id: string;
|
||||
secret: string;
|
||||
endpoint: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export function OlmInstallCommands({
|
||||
id,
|
||||
secret,
|
||||
endpoint,
|
||||
version = "latest"
|
||||
endpoint
|
||||
}: OlmInstallCommandsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -40,14 +42,28 @@ export function OlmInstallCommands({
|
||||
|
||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||
unix: {
|
||||
All: [
|
||||
Run: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
||||
},
|
||||
{
|
||||
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: {
|
||||
x64: [
|
||||
Run: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `# Download and run the installer to install Olm first\n
|
||||
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
|
||||
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
|
||||
},
|
||||
{
|
||||
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>
|
||||
label={
|
||||
platform === "docker" ? t("method") : t("architecture")
|
||||
}
|
||||
label={t("method")}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: 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>
|
||||
<div className="mt-2 space-y-3">
|
||||
{commands.map((item, index) => {
|
||||
const isLink =
|
||||
typeof item !== "string" && "link" in item;
|
||||
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 =
|
||||
typeof item === "string"
|
||||
? undefined
|
||||
@@ -152,10 +188,23 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
<CopyTextBox
|
||||
text={commandText}
|
||||
outline={true}
|
||||
/>
|
||||
{isLink ? (
|
||||
<Button
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -169,13 +218,13 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
function getArchitectures(platform: Platform) {
|
||||
switch (platform) {
|
||||
case "unix":
|
||||
return ["All"];
|
||||
return ["Run", "Service"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
return ["Run", "Service"];
|
||||
case "docker":
|
||||
return ["Docker Compose", "Docker Run"];
|
||||
default:
|
||||
return ["x64"];
|
||||
return ["Run"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ export function pullEnv(): Env {
|
||||
: false,
|
||||
disableEnterpriseFeatures:
|
||||
process.env.DISABLE_ENTERPRISE_FEATURES === "true"
|
||||
? true
|
||||
: false,
|
||||
disableVirtualApiKeysUi:
|
||||
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI === "true"
|
||||
? true
|
||||
: false
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ export type Env = {
|
||||
usePangolinDns: boolean;
|
||||
disableProductHelpBanners: boolean;
|
||||
disableEnterpriseFeatures: boolean;
|
||||
disableVirtualApiKeysUi: boolean;
|
||||
};
|
||||
branding: {
|
||||
appName?: string;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@server/*": [
|
||||
|
||||