Compare commits

..

23 Commits

Author SHA1 Message Date
Owen
3af1e0ef56 Delete all before migrating 2025-10-17 11:56:19 -07:00
Owen
08b7d6735c Priority needs to be def 2025-10-16 14:52:14 -07:00
Milo Schwartz
a91ebd1e91 Update README.md 2025-10-16 17:45:11 -04:00
Owen
312e03b4eb Fix typo 2025-10-16 14:43:11 -07:00
miloschwartz
e8a57e432c hide path match and rewrite in raw resource 2025-10-16 14:30:22 -07:00
Owen
bca2eef2e8 Show ssl toggle 2025-10-16 14:24:36 -07:00
Owen
ec7211a15d Handle updating exit node and fix raw resource issues 2025-10-16 13:55:08 -07:00
Owen
46807c6477 Fix various bugs 2025-10-16 10:23:25 -07:00
miloschwartz
b578786e62 add empty state to sites table cols 2025-10-16 10:11:50 -07:00
miloschwartz
2e0ad8d262 branding only works when licensed 2025-10-15 22:07:33 -07:00
miloschwartz
003f0cfa6d fix target validation on create site 2025-10-15 20:43:59 -07:00
Owen
ee3df081ef Fix docker button and positioning 2025-10-15 20:21:15 -07:00
Owen
08eeb12519 Fix going away when creating target
cd8062ada3
2025-10-15 17:48:31 -07:00
Owen
e66c6b2505 remove volumes for remote nodes 2025-10-15 17:44:03 -07:00
miloschwartz
d2a880d9c8 update docker command in makefile 2025-10-15 17:36:09 -07:00
miloschwartz
edc0b86470 add translation and update url 2025-10-15 17:32:39 -07:00
Owen
aebe6b80b7 Make private file optional 2025-10-15 17:22:43 -07:00
Owen
4d87333b43 Merge branch 'main' into dev 2025-10-15 17:15:48 -07:00
Owen
ef32f3ed5a Load encryption file dynamically 2025-10-15 17:14:24 -07:00
Owen
216ded3034 Merge branch 'main' of github.com:fosrl/pangolin 2025-10-15 17:14:14 -07:00
miloschwartz
cb59fe2cee update readme 2025-10-15 16:34:06 -07:00
miloschwartz
7776f6d09c disable branding 2025-10-15 16:32:16 -07:00
Milo Schwartz
ba96332313 Update README.md 2025-10-15 14:02:28 -04:00
32 changed files with 798 additions and 580 deletions

View File

@@ -2,13 +2,13 @@
major_tag := $(shell echo $(tag) | cut -d. -f1)
minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
build-release-arm:
build-release:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \
--build-arg BUILD=oss
--build-arg BUILD=oss \
--build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:latest \
@@ -17,7 +17,7 @@ build-release-arm:
--tag fosrl/pangolin:$(tag) \
--push .
docker buildx build \
--build-arg BUILD=oss
--build-arg BUILD=oss \
--build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:postgresql-latest \
@@ -26,7 +26,7 @@ build-release-arm:
--tag fosrl/pangolin:postgresql-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise
--build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-latest \
@@ -35,7 +35,7 @@ build-release-arm:
--tag fosrl/pangolin:ee-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise
--build-arg BUILD=enterprise \
--build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-postgresql-latest \

View File

@@ -51,7 +51,8 @@ Check out the [quick install guide](https://docs.digpangolin.com/self-host/quick
| <img width=500 /> | Description |
|-----------------|--------------|
| **Self-Host** | Free, open source, and AGPL-3 compliant. |
| **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 earning under \$100K USD annually. |
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.digpangolin.com/manage/remote-node/nodes) and connect to our control plane. |
## Key Features
@@ -60,10 +61,10 @@ Pangolin packages everything you need for seamless application access and exposu
| <img width=500 /> | <img width=500 /> |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" /><tr></tr> |
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" width=500 /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" width=500 /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" width=500 /><tr></tr> |
## Get Started

View File

@@ -20,7 +20,7 @@ services:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3003
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:

View File

@@ -6,8 +6,6 @@ services:
restart: unless-stopped
volumes:
- ./config:/app/config
- pangolin-data-certificates:/var/certificates
- pangolin-data-dynamic:/var/dynamic
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "10s"
@@ -22,7 +20,7 @@ services:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3003
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
@@ -56,16 +54,9 @@ services:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
# Shared volume for certificates and dynamic config in file mode
- pangolin-data-certificates:/var/certificates:ro
- pangolin-data-dynamic:/var/dynamic:ro
networks:
default:
driver: bridge
name: pangolin
{{if .EnableIPv6}} enable_ipv6: true{{end}}
volumes:
pangolin-data-dynamic:
pangolin-data-certificates:
{{if .EnableIPv6}} enable_ipv6: true{{end}}

View File

@@ -1893,5 +1893,6 @@
"pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip",
"pathRewriteStripLabel": "strip"
"pathRewriteStripLabel": "strip",
"sidebarEnableEnterpriseLicense": "Enable Enterprise License"
}

View File

@@ -19,11 +19,16 @@ import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
async function startServers() {
await setHostMeta();
await config.initServer();
license.setServerSecret(config.getRawConfig().server.secret!);
await license.check();
await runSetupFunctions();
initTelemetryClient();

View File

@@ -3,11 +3,9 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import { db } from "@server/db";
import { SupporterKey, supporterKey } from "@server/db";
import { eq } from "drizzle-orm";
import { license } from "#dynamic/license/license";
import { configSchema, readConfigFile } from "./readConfigFile";
import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import logger from "@server/logger";
export class Config {
private rawConfig!: z.infer<typeof configSchema>;
@@ -103,16 +101,10 @@ export class Config {
throw new Error("Config not loaded. Call load() first.");
}
license.setServerSecret(this.rawConfig.server.secret!);
await this.checkKeyStatus();
}
private async checkKeyStatus() {
if (build === "enterprise") {
await license.check();
}
if (build == "oss") {
this.checkSupporterKey();
}

View File

@@ -1,5 +1,15 @@
import { db, targetHealthCheck } from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
@@ -78,7 +88,13 @@ export async function getTraefikConfig(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)` // only allow local sites if "local" is in siteTypes
)
),
or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record

View File

@@ -19,18 +19,26 @@ import * as fs from "fs";
import NodeCache from "node-cache";
import logger from "@server/logger";
const encryptionKeyPath =
config.getRawPrivateConfig().server.encryption_key_path;
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
const encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
// Define the return type for clarity and type safety
export type CertificateResult = {
id: number;
@@ -50,6 +58,9 @@ export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
@@ -151,7 +162,9 @@ export async function getValidCertificatesForDomains(
// If a certificate was found, format it, add to results, and cache it
if (foundCert) {
logger.debug(`Creating result cert for ${domain} using cert from ${foundCert.domain}`);
logger.debug(
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
);
const resultCert: CertificateResult = {
id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record
@@ -172,7 +185,6 @@ export async function getValidCertificatesForDomains(
}
}
const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults;
}

View File

@@ -19,7 +19,6 @@ import {
privateConfigSchema,
readPrivateConfigFile
} from "#private/lib/readConfigFile";
import { build } from "@server/build";
export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -44,115 +43,104 @@ export class PrivateConfig {
throw new Error(`Invalid private configuration file: ${errors}`);
}
if (parsedPrivateConfig.branding?.colors) {
this.rawPrivateConfig = parsedPrivateConfig;
if (this.rawPrivateConfig.branding?.colors) {
process.env.BRANDING_COLORS = JSON.stringify(
parsedPrivateConfig.branding?.colors
this.rawPrivateConfig.branding?.colors
);
}
if (parsedPrivateConfig.branding?.logo?.light_path) {
if (this.rawPrivateConfig.branding?.logo?.light_path) {
process.env.BRANDING_LOGO_LIGHT_PATH =
parsedPrivateConfig.branding?.logo?.light_path;
this.rawPrivateConfig.branding?.logo?.light_path;
}
if (parsedPrivateConfig.branding?.logo?.dark_path) {
if (this.rawPrivateConfig.branding?.logo?.dark_path) {
process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined;
this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
}
if (build != "oss") {
if (parsedPrivateConfig.branding?.logo?.light_path) {
process.env.BRANDING_LOGO_LIGHT_PATH =
parsedPrivateConfig.branding?.logo?.light_path;
}
if (parsedPrivateConfig.branding?.logo?.dark_path) {
process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined;
}
process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
?.logo?.auth_page?.width
? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = this.rawPrivateConfig.branding
?.logo?.auth_page?.height
? this.rawPrivateConfig.branding?.logo?.auth_page?.height.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_WIDTH = parsedPrivateConfig.branding
?.logo?.auth_page?.width
? parsedPrivateConfig.branding?.logo?.auth_page?.width.toString()
: undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = parsedPrivateConfig.branding
?.logo?.auth_page?.height
? parsedPrivateConfig.branding?.logo?.auth_page?.height.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = this.rawPrivateConfig.branding
?.logo?.navbar?.width
? this.rawPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_HEIGHT = this.rawPrivateConfig.branding
?.logo?.navbar?.height
? this.rawPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = parsedPrivateConfig
.branding?.logo?.navbar?.width
? parsedPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined;
process.env.BRANDING_LOGO_NAVBAR_HEIGHT = parsedPrivateConfig
.branding?.logo?.navbar?.height
? parsedPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined;
process.env.BRANDING_FAVICON_PATH =
this.rawPrivateConfig.branding?.favicon_path;
process.env.BRANDING_FAVICON_PATH =
parsedPrivateConfig.branding?.favicon_path;
process.env.BRANDING_APP_NAME =
this.rawPrivateConfig.branding?.app_name || "Pangolin";
process.env.BRANDING_APP_NAME =
parsedPrivateConfig.branding?.app_name || "Pangolin";
if (parsedPrivateConfig.branding?.footer) {
process.env.BRANDING_FOOTER = JSON.stringify(
parsedPrivateConfig.branding?.footer
);
}
process.env.LOGIN_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
parsedPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
parsedPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
parsedPrivateConfig.branding?.resource_auth_page
?.subtitle_text || "";
if (parsedPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
parsedPrivateConfig.branding?.background_image_path;
}
if (parsedPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
parsedPrivateConfig.server.reo_client_id;
}
if (parsedPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = parsedPrivateConfig.stripe.s3Bucket;
}
if (parsedPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
parsedPrivateConfig.stripe.localFilePath;
}
if (parsedPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = parsedPrivateConfig.stripe.s3Region;
}
if (parsedPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
parsedPrivateConfig.flags.use_pangolin_dns.toString();
}
if (this.rawPrivateConfig.branding?.footer) {
process.env.BRANDING_FOOTER = JSON.stringify(
this.rawPrivateConfig.branding?.footer
);
}
this.rawPrivateConfig = parsedPrivateConfig;
process.env.LOGIN_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.title_text || "";
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";
process.env.SIGNUP_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.title_text || "";
process.env.SIGNUP_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.signup_page?.subtitle_text || "";
process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY =
this.rawPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO =
this.rawPrivateConfig.branding?.resource_auth_page?.show_logo ===
true
? "true"
: "false";
process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.title_text ||
"";
process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.resource_auth_page?.subtitle_text ||
"";
if (this.rawPrivateConfig.branding?.background_image_path) {
process.env.BACKGROUND_IMAGE_PATH =
this.rawPrivateConfig.branding?.background_image_path;
}
if (this.rawPrivateConfig.server.reo_client_id) {
process.env.REO_CLIENT_ID =
this.rawPrivateConfig.server.reo_client_id;
}
if (this.rawPrivateConfig.stripe?.s3Bucket) {
process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket;
}
if (this.rawPrivateConfig.stripe?.localFilePath) {
process.env.LOCAL_FILE_PATH =
this.rawPrivateConfig.stripe.localFilePath;
}
if (this.rawPrivateConfig.stripe?.s3Region) {
process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region;
}
if (this.rawPrivateConfig.flags.use_pangolin_dns) {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
}
public getRawPrivateConfig() {

View File

@@ -172,6 +172,15 @@ export function readPrivateConfigFile() {
return {};
}
// test if the config file is there
if (!fs.existsSync(privateConfigFilePath1)) {
// console.warn(
// `Private configuration file not found at ${privateConfigFilePath1}. Using default configuration.`
// );
// load the default values of the zod schema and return those
return privateConfigSchema.parse({});
}
const loadConfig = (configPath: string) => {
try {
const yamlContent = fs.readFileSync(configPath, "utf8");

View File

@@ -19,7 +19,17 @@ import {
loginPage,
targetHealthCheck
} from "@server/db";
import { and, eq, inArray, or, isNull, ne, isNotNull, desc } from "drizzle-orm";
import {
and,
eq,
inArray,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db";
@@ -110,15 +120,19 @@ export async function getTraefikConfig(
and(
eq(targets.enabled, true),
eq(resources.enabled, true),
// or(
eq(sites.exitNodeId, exitNodeId),
// isNull(sites.exitNodeId)
// ),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)` // only allow local sites if "local" is in siteTypes
)
),
or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
),
inArray(sites.type, siteTypes),
// lets rewrite this using sql
config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)

View File

@@ -61,7 +61,15 @@ export async function createExitNode(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else {
exitNode = exitNodeQuery;
// update the reachable at
[exitNode] = await db
.update(exitNodes)
.set({
reachableAt
})
.where(eq(exitNodes.exitNodeId, exitNodeQuery.exitNodeId))
.returning();
logger.info(`Updated exit node reachableAt to ${reachableAt}`);
}
return exitNode;

View File

@@ -292,11 +292,33 @@ hybridRouter.get(
}
);
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get(
"/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => {
try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query
);
@@ -425,20 +447,6 @@ hybridRouter.get(
filtered.push(cert);
}
const encryptionKeyPath =
privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
const encryptionKeyHex = fs
.readFileSync(encryptionKeyPath, "utf8")
.trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
const result = filtered.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(

View File

@@ -52,7 +52,7 @@ export async function createExitNode(publicKey: string, reachableAt: string | un
.where(eq(exitNodes.publicKey, publicKey))
.returning();
logger.info(`Updated exit node`);
logger.info(`Updated exit node with reachableAt to ${reachableAt}`);
}
return exitNode;

View File

@@ -117,27 +117,4 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
};
return configResponse;
}
async function getNextAvailablePort(): Promise<number> {
// Get all existing ports from exitNodes table
const existingPorts = await db
.select({
listenPort: exitNodes.listenPort
})
.from(exitNodes);
// Find the first available port between 1024 and 65535
let nextPort = config.getRawConfig().gerbil.start_port;
for (const port of existingPorts) {
if (port.listenPort > nextPort) {
break;
}
nextPort++;
if (nextPort > 65535) {
throw new Error("No available ports remaining in space");
}
}
return nextPort;
}
}

View File

@@ -46,15 +46,24 @@ const createTargetSchema = z
.optional()
.nullable(),
hcTimeout: z.number().int().positive().min(1).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional().nullable()
})
.strict();
@@ -164,12 +173,14 @@ export async function createTarget(
let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
.values({
resourceId,
...targetData
...targetData,
priority: targetData.priority || 100
})
.returning();
} else {
@@ -186,7 +197,7 @@ export async function createTarget(
);
}
const { internalPort, targetIps } = await pickPort(
const { internalPort, targetIps: newTargetIps } = await pickPort(
site.siteId!,
db
);
@@ -214,61 +225,63 @@ export async function createTarget(
pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType,
priority: targetData.priority
})
.returning();
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
priority: targetData.priority || 100
})
.returning();
// add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
newTargetIps.push(`${targetData.ip}/32`);
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
targetIps = newTargetIps;
}
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
}
let hcHeaders = null;
if (targetData.hcHeaders) {
hcHeaders = JSON.stringify(targetData.hcHeaders);
}
healthCheck = await db
.insert(targetHealthCheck)
.values({
targetId: newTarget[0].targetId,
hcEnabled: targetData.hcEnabled ?? false,
hcPath: targetData.hcPath ?? null,
hcScheme: targetData.hcScheme ?? null,
hcMode: targetData.hcMode ?? null,
hcHostname: targetData.hcHostname ?? null,
hcPort: targetData.hcPort ?? null,
hcInterval: targetData.hcInterval ?? null,
hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null,
hcTimeout: targetData.hcTimeout ?? null,
hcHeaders: hcHeaders,
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown"
})
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await addTargets(
newt.newtId,
newTarget,
healthCheck,
resource.protocol,
resource.proxyPort
);
}
}

View File

@@ -313,6 +313,11 @@ export default async function migration() {
dateCreated: string;
}[];
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials";
`);
for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array(
@@ -325,12 +330,6 @@ export default async function migration() {
)
);
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials"
WHERE "credentialId" = ${webauthnCredential.credentialId}
`);
// Insert the updated record with converted values
await db.execute(sql`
INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated")

View File

@@ -269,6 +269,8 @@ export default async function migration() {
dateCreated: string;
}[];
db.prepare(`DELETE FROM 'webauthnCredentials';`).run();
for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array(
@@ -281,11 +283,6 @@ export default async function migration() {
)
);
// Delete the old record
db.prepare(
`DELETE FROM 'webauthnCredentials' WHERE 'credentialId' = ?`
).run(webauthnCredential.credentialId);
// Insert the updated record with converted values
db.prepare(
`INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`

View File

@@ -135,7 +135,7 @@ const addTargetSchema = z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000)
priority: z.number().int().min(1).max(1000).optional()
})
.refine(
(data) => {
@@ -205,6 +205,7 @@ export default function ReverseProxyTargets(props: {
}) {
const params = use(props.params);
const t = useTranslations();
const { env } = useEnvContext();
const { resource, updateResource } = useResourceContext();
@@ -428,17 +429,19 @@ export default function ReverseProxyTargets(props: {
}, [isAdvancedMode]);
function addNewTarget() {
const isHttp = resource.http;
const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "",
method: resource.http ? "http" : null,
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
path: isHttp ? null : null,
pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null,
rewritePathType: isHttp ? null : null,
priority: isHttp ? 100 : 100,
enabled: true,
resourceId: resource.resourceId,
hcEnabled: false,
@@ -514,25 +517,31 @@ export default function ReverseProxyTargets(props: {
try {
setTargetsLoading(true);
const response = await api.post<
AxiosResponse<CreateTargetResponse>
>(`/target`, {
const data: any = {
resourceId: resource.resourceId,
siteId: target.siteId,
ip: target.ip,
method: target.method,
port: target.port,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority,
enabled: target.enabled,
hcEnabled: target.hcEnabled,
hcPath: target.hcPath,
hcInterval: target.hcInterval,
hcTimeout: target.hcTimeout
});
};
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
const response = await api.post<
AxiosResponse<CreateTargetResponse>
>(`/target`, data);
if (response.status === 200) {
// Update the target with the new ID and remove the new flag
@@ -615,19 +624,20 @@ export default function ReverseProxyTargets(props: {
// }
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = resource.http;
const newTarget: LocalTarget = {
...data,
path: data.path || null,
pathMatchType: data.pathMatchType || null,
rewritePath: data.rewritePath || null,
rewritePathType: data.rewritePathType || null,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
priority: 100,
priority: isHttp ? (data.priority || 100) : 100,
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -666,7 +676,7 @@ export default function ReverseProxyTargets(props: {
...target,
...data,
updated: true,
// siteType: site?.type || null
siteType: site ? site.type : target.siteType
}
: target
)
@@ -719,7 +729,7 @@ export default function ReverseProxyTargets(props: {
// Save targets
for (const target of targets) {
const data = {
const data: any = {
ip: target.ip,
port: target.port,
method: target.method,
@@ -735,14 +745,18 @@ export default function ReverseProxyTargets(props: {
hcHeaders: target.hcHeaders || null,
hcFollowRedirects: target.hcFollowRedirects || null,
hcMethod: target.hcMethod || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
hcStatus: target.hcStatus || null
};
// Only include path-related fields for HTTP resources
if (resource.http) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
if (target.new) {
const res = await api.put<
AxiosResponse<CreateTargetResponse>
@@ -814,6 +828,7 @@ export default function ReverseProxyTargets(props: {
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = resource.http;
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -1007,14 +1022,9 @@ export default function ReverseProxyTargets(props: {
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname
ip: hostname,
...(port && { port: port })
});
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
};
return (
@@ -1051,12 +1061,12 @@ export default function ReverseProxyTargets(props: {
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent",
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[90px]">
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
@@ -1133,7 +1143,7 @@ export default function ReverseProxyTargets(props: {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
@@ -1317,15 +1327,20 @@ export default function ReverseProxyTargets(props: {
};
if (isAdvancedMode) {
return [
matchPathColumn,
const columns = [
addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn,
enabledColumn,
actionsColumn
];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else {
return [
addressColumn,
@@ -1454,7 +1469,7 @@ export default function ReverseProxyTargets(props: {
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm font-medium"
className="text-sm"
>
{t("advancedMode")}
</label>
@@ -1496,7 +1511,7 @@ export default function ReverseProxyTargets(props: {
className="space-y-4"
id="tls-settings-form"
>
{build == "oss" && (
{!env.flags.usePangolinDns && (
<FormField
control={tlsSettingsForm.control}
name="ssl"

View File

@@ -25,7 +25,6 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
@@ -58,7 +57,16 @@ import {
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import { ArrowRight, CircleCheck, CircleX, Info, MoveRight, Plus, Settings, SquareArrowOutUpRight } from "lucide-react";
import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
@@ -89,16 +97,25 @@ import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from 'punycode';
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "../../../../../components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import { SwitchInput } from "@app/components/SwitchInput";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
http: z.boolean()
@@ -115,54 +132,57 @@ const tcpUdpResourceFormSchema = z.object({
// enableProxy: z.boolean().default(false)
});
const targetsSettingsSchema = z.object({
stickySession: z.boolean()
});
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(),
priority: z.number().int().min(1).max(1000)
}).refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
const addTargetSchema = z
.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive(),
siteId: z.number().int().positive(),
path: z.string().optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
.nullable(),
rewritePath: z.string().optional().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional()
})
.refine(
(data) => {
// If path is provided, pathMatchType must be provided
if (data.path && !data.pathMatchType) {
return false;
}
// If pathMatchType is provided, path must be provided
if (data.pathMatchType && !data.path) {
return false;
}
// Validate path based on pathMatchType
if (data.path && data.pathMatchType) {
switch (data.pathMatchType) {
case "exact":
case "prefix":
// Path should start with /
return data.path.startsWith("/");
case "regex":
// Validate regex
try {
new RegExp(data.path);
return true;
} catch {
return false;
}
}
}
return true;
},
{
message: "Invalid path configuration"
}
return true;
},
{
message: "Invalid path configuration"
}
)
)
.refine(
(data) => {
// If rewritePath is provided, rewritePathType must be provided
@@ -216,12 +236,14 @@ export default function Page() {
>([]);
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null);
const [niceId, setNiceId] = useState<string>("");
// Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map());
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
@@ -246,17 +268,19 @@ export default function Page() {
}, [isAdvancedMode]);
function addNewTarget() {
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
targetId: -Date.now(), // Use negative timestamp as temporary ID
ip: "",
method: baseForm.watch("http") ? "http" : null,
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
path: isHttp ? null : null,
pathMatchType: isHttp ? null : null,
rewritePath: isHttp ? null : null,
rewritePathType: isHttp ? null : null,
priority: isHttp ? 100 : 100,
enabled: true,
resourceId: 0,
hcEnabled: false,
@@ -290,12 +314,12 @@ export default function Page() {
...(!env.flags.allowRawResources
? []
: [
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
{
id: "raw" as ResourceType,
title: t("resourceRaw"),
description: t("resourceRawDescription")
}
])
];
const baseForm = useForm({
@@ -330,26 +354,39 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
priority: baseForm.watch("http") ? 100 : undefined
} as z.infer<typeof addTargetSchema>
});
const targetsSettingsForm = useForm({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: false
}
});
// Helper function to check if all targets have required fields using schema validation
const areAllTargetsValid = () => {
if (targets.length === 0) return true; // No targets is valid
const watchedIp = addTargetForm.watch("ip");
const watchedPort = addTargetForm.watch("port");
const watchedSiteId = addTargetForm.watch("siteId");
return targets.every((target) => {
try {
const isHttp = baseForm.watch("http");
const targetData: any = {
ip: target.ip,
method: target.method,
port: target.port,
siteId: target.siteId,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType
};
const handleContainerSelect = (hostname: string, port?: number) => {
addTargetForm.setValue("ip", hostname);
if (port) {
addTargetForm.setValue("port", port);
}
// Only include priority for HTTP resources
if (isHttp) {
targetData.priority = target.priority;
}
addTargetSchema.parse(targetData);
return true;
} catch {
return false;
}
});
};
const initializeDockerForSite = async (siteId: number) => {
@@ -360,14 +397,14 @@ export default function Page() {
const dockerManager = new DockerManager(api, siteId);
const dockerState = await dockerManager.initializeDocker();
setDockerStates(prev => new Map(prev.set(siteId, dockerState)));
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates(prev => {
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
@@ -378,11 +415,13 @@ export default function Page() {
};
const getDockerStateForSite = (siteId: number): DockerState => {
return dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
};
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
};
async function addTarget(data: z.infer<typeof addTargetSchema>) {
@@ -406,18 +445,20 @@ export default function Page() {
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
...data,
path: data.path || null,
pathMatchType: data.pathMatchType || null,
rewritePath: data.rewritePath || null,
rewritePathType: data.rewritePathType || null,
path: isHttp ? (data.path || null) : null,
pathMatchType: isHttp ? (data.pathMatchType || null) : null,
rewritePath: isHttp ? (data.rewritePath || null) : null,
rewritePathType: isHttp ? (data.rewritePathType || null) : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: 100, // Default priority
priority: isHttp ? (data.priority || 100) : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
@@ -443,7 +484,7 @@ export default function Page() {
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: 100,
priority: isHttp ? 100 : undefined
});
}
@@ -463,11 +504,11 @@ export default function Page() {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site?.type || null
}
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
@@ -478,13 +519,11 @@ export default function Page() {
const baseData = baseForm.getValues();
const isHttp = baseData.http;
const stickySessionData = targetsSettingsForm.getValues();
try {
const payload = {
name: baseData.name,
http: baseData.http,
stickySession: stickySessionData.stickySession
};
let sanitizedSubdomain: string | undefined;
@@ -497,7 +536,9 @@ export default function Page() {
: undefined;
Object.assign(payload, {
subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined,
subdomain: sanitizedSubdomain
? toASCII(sanitizedSubdomain)
: undefined,
domainId: httpData.domainId,
protocol: "tcp"
});
@@ -528,13 +569,13 @@ export default function Page() {
if (res && res.status === 201) {
const id = res.data.data.resourceId;
const niceId = res.data.data.niceId;
setResourceId(id);
setNiceId(niceId);
// Create targets if any exist
if (targets.length > 0) {
try {
for (const target of targets) {
const data = {
const data: any = {
ip: target.ip,
port: target.port,
method: target.method,
@@ -551,14 +592,18 @@ export default function Page() {
hcPort: target.hcPort || null,
hcFollowRedirects:
target.hcFollowRedirects || null,
hcStatus: target.hcStatus || null,
path: target.path,
pathMatchType: target.pathMatchType,
rewritePath: target.rewritePath,
rewritePathType: target.rewritePathType,
priority: target.priority
hcStatus: target.hcStatus || null
};
// Only include path-related fields for HTTP resources
if (isHttp) {
data.path = target.path;
data.pathMatchType = target.pathMatchType;
data.rewritePath = target.rewritePath;
data.rewritePathType = target.rewritePathType;
data.priority = target.priority;
}
await api.put(`/resource/${id}/target`, data);
}
} catch (targetError) {
@@ -660,7 +705,7 @@ export default function Page() {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain),
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
// if (domains.length) {
@@ -683,10 +728,10 @@ export default function Page() {
targets.map((target) =>
target.targetId === targetId
? {
...target,
...config,
updated: true
}
...target,
...config,
updated: true
}
: target
)
);
@@ -700,6 +745,7 @@ export default function Page() {
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -712,9 +758,7 @@ export default function Page() {
<Info className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>
{t("priorityDescription")}
</p>
<p>{t("priorityDescription")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -895,31 +939,51 @@ export default function Page() {
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname
ip: hostname,
...(port && { port: port })
});
if (port) {
updateTarget(row.original.targetId, {
...row.original,
port: port
});
}
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm font-medium border-r pr-4 rounded-none h-8 hover:bg-transparent",
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
"text-muted-foreground"
)}
>
<span className="truncate max-w-[90px]">
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
@@ -969,30 +1033,6 @@ export default function Page() {
</Command>
</PopoverContent>
</Popover>
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Select
defaultValue={row.original.method ?? "http"}
@@ -1003,7 +1043,7 @@ export default function Page() {
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
@@ -1020,7 +1060,7 @@ export default function Page() {
<Input
defaultValue={row.original.ip}
placeholder="IP / Hostname"
className="flex-1 min-w-[120px] border-none placeholder-gray-400"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
@@ -1204,15 +1244,20 @@ export default function Page() {
};
if (isAdvancedMode) {
return [
matchPathColumn,
const columns = [
addressColumn,
rewritePathColumn,
priorityColumn,
healthCheckColumn,
enabledColumn,
actionsColumn
];
// Only include path-related columns for HTTP resources
if (isHttp) {
columns.unshift(matchPathColumn);
columns.splice(3, 0, rewritePathColumn, priorityColumn);
}
return columns;
} else {
return [
addressColumn,
@@ -1464,10 +1509,10 @@ export default function Page() {
.target
.value
? parseInt(
e
.target
.value
)
e
.target
.value
)
: undefined
)
}
@@ -1546,60 +1591,87 @@ export default function Page() {
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
.map(
(
headerGroup
) => (
<TableRow
key={
headerGroup.id
}
>
{headerGroup.headers.map(
(
header
) => (
<TableHead
key={
cell.id
header.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
)}
</TableRow>
))
)
)}
</TableHeader>
<TableBody>
{table.getRowModel()
.rows?.length ? (
table
.getRowModel()
.rows.map(
(row) => (
<TableRow
key={
row.id
}
>
{row
.getVisibleCells()
.map(
(
cell
) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
)
)}
</TableRow>
)
)
) : (
<TableRow>
<TableCell
colSpan={columns.length}
colSpan={
columns.length
}
className="h-24 text-center"
>
{t("targetNoOne")}
{t(
"targetNoOne"
)}
</TableCell>
</TableRow>
)}
@@ -1621,12 +1693,16 @@ export default function Page() {
<div className="flex items-center gap-2">
<Switch
id="advanced-mode-toggle"
checked={isAdvancedMode}
onCheckedChange={setIsAdvancedMode}
checked={
isAdvancedMode
}
onCheckedChange={
setIsAdvancedMode
}
/>
<label
htmlFor="advanced-mode-toggle"
className="text-sm font-medium"
className="text-sm"
>
{t("advancedMode")}
</label>
@@ -1639,7 +1715,10 @@ export default function Page() {
<p className="text-muted-foreground mb-4">
{t("targetNoOne")}
</p>
<Button onClick={addNewTarget} variant="outline">
<Button
onClick={addNewTarget}
variant="outline"
>
<Plus className="h-4 w-4 mr-2" />
{t("addTarget")}
</Button>
@@ -1677,6 +1756,7 @@ export default function Page() {
}
}}
loading={createLoading}
disabled={!areAllTargetsValid()}
>
{t("resourceCreate")}
</Button>
@@ -1685,24 +1765,36 @@ export default function Page() {
<HealthCheckDialog
open={healthCheckDialogOpen}
setOpen={setHealthCheckDialogOpen}
targetId={selectedTargetForHealthCheck.targetId}
targetId={
selectedTargetForHealthCheck.targetId
}
targetAddress={`${selectedTargetForHealthCheck.ip}:${selectedTargetForHealthCheck.port}`}
targetMethod={
selectedTargetForHealthCheck.method || undefined
selectedTargetForHealthCheck.method ||
undefined
}
initialConfig={{
hcEnabled:
selectedTargetForHealthCheck.hcEnabled || false,
hcPath: selectedTargetForHealthCheck.hcPath || "/",
selectedTargetForHealthCheck.hcEnabled ||
false,
hcPath:
selectedTargetForHealthCheck.hcPath ||
"/",
hcMethod:
selectedTargetForHealthCheck.hcMethod || "GET",
selectedTargetForHealthCheck.hcMethod ||
"GET",
hcInterval:
selectedTargetForHealthCheck.hcInterval || 5,
hcTimeout: selectedTargetForHealthCheck.hcTimeout || 5,
selectedTargetForHealthCheck.hcInterval ||
5,
hcTimeout:
selectedTargetForHealthCheck.hcTimeout ||
5,
hcHeaders:
selectedTargetForHealthCheck.hcHeaders || undefined,
selectedTargetForHealthCheck.hcHeaders ||
undefined,
hcScheme:
selectedTargetForHealthCheck.hcScheme || undefined,
selectedTargetForHealthCheck.hcScheme ||
undefined,
hcHostname:
selectedTargetForHealthCheck.hcHostname ||
selectedTargetForHealthCheck.ip,
@@ -1713,8 +1805,11 @@ export default function Page() {
selectedTargetForHealthCheck.hcFollowRedirects ||
true,
hcStatus:
selectedTargetForHealthCheck.hcStatus || undefined,
hcMode: selectedTargetForHealthCheck.hcMode || "http",
selectedTargetForHealthCheck.hcStatus ||
undefined,
hcMode:
selectedTargetForHealthCheck.hcMode ||
"http",
hcUnhealthyInterval:
selectedTargetForHealthCheck.hcUnhealthyInterval ||
30
@@ -1749,7 +1844,9 @@ export default function Page() {
{t("resourceAddEntrypoints")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceAddEntrypointsEditFile")}
{t(
"resourceAddEntrypointsEditFile"
)}
</p>
<CopyTextBox
text={`entryPoints:
@@ -1764,7 +1861,9 @@ export default function Page() {
{t("resourceExposePorts")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceExposePortsEditFile")}
{t(
"resourceExposePortsEditFile"
)}
</p>
<CopyTextBox
text={`ports:
@@ -1802,7 +1901,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${resourceId}/proxy`
`/${orgId}/settings/resources/${niceId}/proxy`
)
}
>

View File

@@ -67,6 +67,12 @@ export default async function RootLayout({
)
)();
licenseStatus = licenseStatusRes.data.data;
} else if (build === "saas") {
licenseStatus = {
isHostLicensed: true,
isLicenseValid: true,
hostId: "saas"
};
} else {
licenseStatus = {
isHostLicensed: false,

View File

@@ -1,6 +1,7 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -13,6 +14,7 @@ type BrandingLogoProps = {
export default function BrandingLogo(props: BrandingLogoProps) {
const { env } = useEnvContext();
const { theme } = useTheme();
const { isUnlocked } = useLicenseStatusContext();
const [path, setPath] = useState<string>(""); // Default logo path
useEffect(() => {
@@ -27,12 +29,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
}
if (lightOrDark === "light") {
return (
env.branding.logo?.lightPath || "/logo/word_mark_black.png"
);
if (isUnlocked() && env.branding.logo?.lightPath) {
return env.branding.logo.lightPath;
}
return "/logo/word_mark_black.png";
}
return env.branding.logo?.darkPath || "/logo/word_mark_white.png";
if (isUnlocked() && env.branding.logo?.darkPath) {
return env.branding.logo.darkPath;
}
return "/logo/word_mark_white.png";
}
const path = getPath();

View File

@@ -16,6 +16,7 @@ import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type DashboardLoginFormProps = {
redirect?: string;
@@ -29,18 +30,22 @@ export default function DashboardLoginForm({
const router = useRouter();
const { env } = useEnvContext();
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
function getSubtitle() {
return t("loginStart");
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58;
return (
<Card className="shadow-md w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
height={logoHeight}
width={logoWidth}
/>
</div>
<div className="text-center space-y-1 pt-3">

View File

@@ -1,15 +1,13 @@
"use client";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -19,6 +17,14 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
: 98;
const logoHeight = isUnlocked()
? env.branding.logo?.navbar?.height || 32
: 32;
useEffect(() => {
function getPath() {
@@ -50,12 +56,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
<BrandingLogo
width={
env.branding.logo?.navbar?.width || 98
}
height={
env.branding.logo?.navbar?.height || 32
}
width={logoWidth}
height={logoHeight}
/>
</Link>
{/* {build === "saas" && (

View File

@@ -67,6 +67,9 @@ export function LayoutSidebar({
}, [isSidebarCollapsed]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (!isUnlocked()) {
return undefined;
}
if (env.branding.footer) {
try {
return JSON.parse(env.branding.footer);

View File

@@ -48,6 +48,7 @@ import BrandingLogo from "@app/components/BrandingLogo";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
const pinSchema = z.object({
pin: z
@@ -92,6 +93,7 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter();
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const getNumMethods = () => {
let colLength = 0;
@@ -308,14 +310,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
function getTitle() {
if (build !== "oss" && env.branding.resourceAuthPage?.titleText) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.titleText
) {
return env.branding.resourceAuthPage.titleText;
}
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
if (build !== "oss" && env.branding.resourceAuthPage?.subtitleText) {
if (
isUnlocked() &&
build !== "oss" &&
env.branding.resourceAuthPage?.subtitleText
) {
return env.branding.resourceAuthPage.subtitleText
.split("{{resourceName}}")
.join(resourceName);
@@ -325,11 +335,14 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
: t("authenticationRequest", { name: resourceName });
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 100 : 100;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 100 : 100;
return (
<div>
{!accessDenied ? (
<div>
{build === "enterprise" ? (
{isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && (
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
@@ -362,18 +375,13 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
)}
<Card>
<CardHeader>
{build !== "oss" &&
{isUnlocked() &&
build !== "oss" &&
env.branding?.resourceAuthPage?.showLogo && (
<div className="flex flex-row items-center justify-center mb-3">
<BrandingLogo
height={
env.branding.logo?.authPage
?.height || 100
}
width={
env.branding.logo?.authPage
?.width || 100
}
height={logoHeight}
width={logoWidth}
/>
</div>
)}

View File

@@ -21,6 +21,8 @@ export default function SidebarLicenseButton({
}: SidebarLicenseButtonProps) {
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const url = "https://docs.digpangolin.com/self-host/enterprise-edition";
const t = useTranslations();
return (
@@ -30,21 +32,21 @@ export default function SidebarLicenseButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href="https://docs.digpangolin.com/">
<Link href={url}>
<Button size="icon" className="w-8 h-8">
<TicketCheck className="h-4 w-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Enable Enterprise License
{t("sidebarEnableEnterpriseLicense")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link href="https://docs.digpangolin.com/">
<Link href={url}>
<Button size="sm" className="gap-2 w-full">
Enable Enterprise License
{t("sidebarEnableEnterpriseLicense")}
</Button>
</Link>
)

View File

@@ -18,9 +18,7 @@ import {
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
@@ -31,13 +29,13 @@ import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import Image from "next/image";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useTranslations } from "next-intl";
import BrandingLogo from "@app/components/BrandingLogo";
import { build } from "@server/build";
import { Check, X } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
// Password strength calculation
const calculatePasswordStrength = (password: string) => {
@@ -111,6 +109,7 @@ export default function SignupForm({
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -192,14 +191,18 @@ export default function SignupForm({
}
};
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={env.branding.logo?.authPage?.height || 58}
width={env.branding.logo?.authPage?.width || 175}
/>
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>

View File

@@ -300,33 +300,65 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
cell: ({ row }) => {
const originalRow = row.original;
return (
return originalRow.exitNodeName ? (
<div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && (
<Badge variant="secondary">Cloud</Badge>
)}
{build == "saas" &&
originalRow.exitNodeName &&
[
"mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
].includes(
originalRow.exitNodeName.toLowerCase()
) && <Badge variant="secondary">Cloud</Badge>}
</div>
);
},
},
...(env.flags.enableClients ? [{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
) : (
"-"
);
}
}] : []),
},
...(env.flags.enableClients
? [
{
accessorKey: "address",
header: ({
column
}: {
column: Column<SiteRow, unknown>;
}) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
}
]
: []),
{
id: "actions",
cell: ({ row }) => {

View File

@@ -3,6 +3,7 @@
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type SplashImageProps = {
children: React.ReactNode;
@@ -11,8 +12,12 @@ type SplashImageProps = {
export default function SplashImage({ children }: SplashImageProps) {
const pathname = usePathname();
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
function showBackgroundImage() {
if (!isUnlocked()) {
return false;
}
if (!env.branding.background_image_path) {
return false;
}

View File

@@ -1,6 +1,7 @@
"use client";
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { build } from "@server/build";
import { LicenseStatus } from "@server/license/license";
import { useState } from "react";