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) major_tag := $(shell echo $(tag) | cut -d. -f1)
minor_tag := $(shell echo $(tag) | cut -d. -f1,2) minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
build-release-arm: build-release:
@if [ -z "$(tag)" ]; then \ @if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \ echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \ exit 1; \
fi fi
docker buildx build \ docker buildx build \
--build-arg BUILD=oss --build-arg BUILD=oss \
--build-arg DATABASE=sqlite \ --build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:latest \ --tag fosrl/pangolin:latest \
@@ -17,7 +17,7 @@ build-release-arm:
--tag fosrl/pangolin:$(tag) \ --tag fosrl/pangolin:$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=oss --build-arg BUILD=oss \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-latest \
@@ -26,7 +26,7 @@ build-release-arm:
--tag fosrl/pangolin:postgresql-$(tag) \ --tag fosrl/pangolin:postgresql-$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise --build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \ --build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-latest \
@@ -35,7 +35,7 @@ build-release-arm:
--tag fosrl/pangolin:ee-$(tag) \ --tag fosrl/pangolin:ee-$(tag) \
--push . --push .
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise --build-arg BUILD=enterprise \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \ --platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-postgresql-latest \ --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 | | <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. | | **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 ## Key Features
@@ -60,10 +61,10 @@ Pangolin packages everything you need for seamless application access and exposu
| <img width=500 /> | <img width=500 /> | | <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> | | **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" /><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" /><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" /><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 ## Get Started

View File

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

View File

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

View File

@@ -1893,5 +1893,6 @@
"pathRewriteExact": "Exact", "pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex", "pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip", "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 { initTelemetryClient } from "./lib/telemetry.js";
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
import { initCleanup } from "#dynamic/cleanup"; import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
async function startServers() { async function startServers() {
await setHostMeta(); await setHostMeta();
await config.initServer(); await config.initServer();
license.setServerSecret(config.getRawConfig().server.secret!);
await license.check();
await runSetupFunctions(); await runSetupFunctions();
initTelemetryClient(); initTelemetryClient();

View File

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

View File

@@ -1,5 +1,15 @@
import { db, targetHealthCheck } from "@server/db"; 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 logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db"; import { resources, sites, Target, targets } from "@server/db";
@@ -78,7 +88,13 @@ export async function getTraefikConfig(
and( and(
eq(targets.enabled, true), eq(targets.enabled, true),
eq(resources.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( or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record 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 NodeCache from "node-cache";
import logger from "@server/logger"; import logger from "@server/logger";
const encryptionKeyPath = let encryptionKeyPath = "";
config.getRawPrivateConfig().server.encryption_key_path; let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
if (!fs.existsSync(encryptionKeyPath)) { encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
throw new Error(
"Encryption key file not found. Please generate one first." 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 // Define the return type for clarity and type safety
export type CertificateResult = { export type CertificateResult = {
id: number; id: number;
@@ -50,6 +58,9 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); 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 a certificate was found, format it, add to results, and cache it
if (foundCert) { 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 = { const resultCert: CertificateResult = {
id: foundCert.certId, id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record domain: foundCert.domain, // The actual domain of the cert record
@@ -172,7 +185,6 @@ export async function getValidCertificatesForDomains(
} }
} }
const decryptedResults = decryptFinalResults(finalResults); const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults; return decryptedResults;
} }

View File

@@ -19,7 +19,6 @@ import {
privateConfigSchema, privateConfigSchema,
readPrivateConfigFile readPrivateConfigFile
} from "#private/lib/readConfigFile"; } from "#private/lib/readConfigFile";
import { build } from "@server/build";
export class PrivateConfig { export class PrivateConfig {
private rawPrivateConfig!: z.infer<typeof privateConfigSchema>; private rawPrivateConfig!: z.infer<typeof privateConfigSchema>;
@@ -44,115 +43,104 @@ export class PrivateConfig {
throw new Error(`Invalid private configuration file: ${errors}`); 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( 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 = 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 = process.env.BRANDING_LOGO_DARK_PATH =
parsedPrivateConfig.branding?.logo?.dark_path || undefined; this.rawPrivateConfig.branding?.logo?.dark_path || undefined;
} }
if (build != "oss") { process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding
if (parsedPrivateConfig.branding?.logo?.light_path) { ?.logo?.auth_page?.width
process.env.BRANDING_LOGO_LIGHT_PATH = ? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString()
parsedPrivateConfig.branding?.logo?.light_path; : undefined;
} process.env.BRANDING_LOGO_AUTH_HEIGHT = this.rawPrivateConfig.branding
if (parsedPrivateConfig.branding?.logo?.dark_path) { ?.logo?.auth_page?.height
process.env.BRANDING_LOGO_DARK_PATH = ? this.rawPrivateConfig.branding?.logo?.auth_page?.height.toString()
parsedPrivateConfig.branding?.logo?.dark_path || undefined; : undefined;
}
process.env.BRANDING_LOGO_AUTH_WIDTH = parsedPrivateConfig.branding process.env.BRANDING_LOGO_NAVBAR_WIDTH = this.rawPrivateConfig.branding
?.logo?.auth_page?.width ?.logo?.navbar?.width
? parsedPrivateConfig.branding?.logo?.auth_page?.width.toString() ? this.rawPrivateConfig.branding?.logo?.navbar?.width.toString()
: undefined; : undefined;
process.env.BRANDING_LOGO_AUTH_HEIGHT = parsedPrivateConfig.branding process.env.BRANDING_LOGO_NAVBAR_HEIGHT = this.rawPrivateConfig.branding
?.logo?.auth_page?.height ?.logo?.navbar?.height
? parsedPrivateConfig.branding?.logo?.auth_page?.height.toString() ? this.rawPrivateConfig.branding?.logo?.navbar?.height.toString()
: undefined; : undefined;
process.env.BRANDING_LOGO_NAVBAR_WIDTH = parsedPrivateConfig process.env.BRANDING_FAVICON_PATH =
.branding?.logo?.navbar?.width this.rawPrivateConfig.branding?.favicon_path;
? 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 = process.env.BRANDING_APP_NAME =
parsedPrivateConfig.branding?.favicon_path; this.rawPrivateConfig.branding?.app_name || "Pangolin";
process.env.BRANDING_APP_NAME = if (this.rawPrivateConfig.branding?.footer) {
parsedPrivateConfig.branding?.app_name || "Pangolin"; process.env.BRANDING_FOOTER = JSON.stringify(
this.rawPrivateConfig.branding?.footer
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();
}
} }
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() { public getRawPrivateConfig() {

View File

@@ -172,6 +172,15 @@ export function readPrivateConfigFile() {
return {}; 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) => { const loadConfig = (configPath: string) => {
try { try {
const yamlContent = fs.readFileSync(configPath, "utf8"); const yamlContent = fs.readFileSync(configPath, "utf8");

View File

@@ -19,7 +19,17 @@ import {
loginPage, loginPage,
targetHealthCheck targetHealthCheck
} from "@server/db"; } 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 logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db"; import { orgs, resources, sites, Target, targets } from "@server/db";
@@ -110,15 +120,19 @@ export async function getTraefikConfig(
and( and(
eq(targets.enabled, true), eq(targets.enabled, true),
eq(resources.enabled, true), eq(resources.enabled, true),
// or( or(
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
// isNull(sites.exitNodeId) and(
// ), isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)` // only allow local sites if "local" is in siteTypes
)
),
or( or(
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
// lets rewrite this using sql
config.getRawConfig().traefik.allow_raw_resources config.getRawConfig().traefik.allow_raw_resources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, 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}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} else { } 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; 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) // Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get( hybridRouter.get(
"/certificates/domains", "/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse( const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query req.query
); );
@@ -425,20 +447,6 @@ hybridRouter.get(
filtered.push(cert); 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) => { const result = filtered.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decryptData( const decryptedCert = decryptData(

View File

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

View File

@@ -117,27 +117,4 @@ export async function generateGerbilConfig(exitNode: ExitNode) {
}; };
return configResponse; 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() .optional()
.nullable(), .nullable(),
hcTimeout: z.number().int().positive().min(1).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(), hcFollowRedirects: z.boolean().optional().nullable(),
hcMethod: z.string().min(1).optional().nullable(), hcMethod: z.string().min(1).optional().nullable(),
hcStatus: z.number().int().optional().nullable(), hcStatus: z.number().int().optional().nullable(),
path: z.string().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(), rewritePath: z.string().optional().nullable(),
rewritePathType: z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), rewritePathType: z
priority: z.number().int().min(1).max(1000) .enum(["exact", "prefix", "regex", "stripPrefix"])
.optional()
.nullable(),
priority: z.number().int().min(1).max(1000).optional().nullable()
}) })
.strict(); .strict();
@@ -164,12 +173,14 @@ export async function createTarget(
let newTarget: Target[] = []; let newTarget: Target[] = [];
let healthCheck: TargetHealthCheck[] = []; let healthCheck: TargetHealthCheck[] = [];
let targetIps: string[] = [];
if (site.type == "local") { if (site.type == "local") {
newTarget = await db newTarget = await db
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
...targetData ...targetData,
priority: targetData.priority || 100
}) })
.returning(); .returning();
} else { } else {
@@ -186,7 +197,7 @@ export async function createTarget(
); );
} }
const { internalPort, targetIps } = await pickPort( const { internalPort, targetIps: newTargetIps } = await pickPort(
site.siteId!, site.siteId!,
db db
); );
@@ -214,61 +225,63 @@ export async function createTarget(
pathMatchType: targetData.pathMatchType, pathMatchType: targetData.pathMatchType,
rewritePath: targetData.rewritePath, rewritePath: targetData.rewritePath,
rewritePathType: targetData.rewritePathType, rewritePathType: targetData.rewritePathType,
priority: targetData.priority priority: targetData.priority || 100
})
.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"
}) })
.returning(); .returning();
// add the new target to the targetIps array // add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`); newTargetIps.push(`${targetData.ip}/32`);
if (site.pubKey) { targetIps = newTargetIps;
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( let hcHeaders = null;
newt.newtId, if (targetData.hcHeaders) {
newTarget, hcHeaders = JSON.stringify(targetData.hcHeaders);
healthCheck, }
resource.protocol,
resource.proxyPort 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; dateCreated: string;
}[]; }[];
// Delete the old record
await db.execute(sql`
DELETE FROM "webauthnCredentials";
`);
for (const webauthnCredential of webauthnCredentials) { for (const webauthnCredential of webauthnCredentials) {
const newCredentialId = isoBase64URL.fromBuffer( const newCredentialId = isoBase64URL.fromBuffer(
new Uint8Array( 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 // Insert the updated record with converted values
await db.execute(sql` await db.execute(sql`
INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated") INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -300,33 +300,65 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
return ( return originalRow.exitNodeName ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span>{originalRow.exitNodeName}</span> <span>{originalRow.exitNodeName}</span>
{build == "saas" && originalRow.exitNodeName && {build == "saas" &&
['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'].includes(originalRow.exitNodeName.toLowerCase()) && ( originalRow.exitNodeName &&
<Badge variant="secondary">Cloud</Badge> [
)} "mercury",
"venus",
"earth",
"mars",
"jupiter",
"saturn",
"uranus",
"neptune"
].includes(
originalRow.exitNodeName.toLowerCase()
) && <Badge variant="secondary">Cloud</Badge>}
</div> </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", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {

View File

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

View File

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