Compare commits

..

36 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
Owen
c50392c947 Remove logging 2025-10-15 13:57:42 -07:00
Owen
ceee978fcd Merge branch 'dev' 2025-10-15 12:13:15 -07:00
Owen
c5a73dc87e Try to handle the certs better 2025-10-15 12:12:59 -07:00
Owen
7198ef2774 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-10-15 11:12:38 -07:00
miloschwartz
7e9a066797 update form 2025-10-15 11:10:37 -07:00
Milo Schwartz
ba96332313 Update README.md 2025-10-15 14:02:28 -04:00
Owen
e2d0338b0b Merge branch 'dev' 2025-10-15 10:39:50 -07:00
Owen
59ecab5738 Dont ping remote nodes; handle certs better 2025-10-15 10:39:45 -07:00
miloschwartz
721bf3403d fix form 2025-10-15 10:21:00 -07:00
Owen
3b8ba47377 Update package lock 2025-10-14 18:00:46 -07:00
Milo Schwartz
e752929f69 Update README.md 2025-10-14 20:50:41 -04:00
Milo Schwartz
e41c3e6f54 Update README.md 2025-10-14 20:48:44 -04:00
Milo Schwartz
9dedd1a8de Update README.md 2025-10-14 20:41:14 -04:00
Owen
c4a5fae28f Update workflow and add runner 2025-10-14 17:34:47 -07:00
37 changed files with 5451 additions and 2259 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs:
release:
name: Build and Release
runs-on: ubuntu-latest
runs-on: amd64-runner
steps:
- name: Checkout code

View File

@@ -8,7 +8,7 @@ build-release:
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:
--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 \
@@ -25,6 +25,24 @@ build-release:
--tag fosrl/pangolin:postgresql-$(minor_tag) \
--tag fosrl/pangolin:postgresql-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-latest \
--tag fosrl/pangolin:ee-$(major_tag) \
--tag fosrl/pangolin:ee-$(minor_tag) \
--tag fosrl/pangolin:ee-$(tag) \
--push .
docker buildx build \
--build-arg BUILD=enterprise \
--build-arg DATABASE=pg \
--platform linux/arm64,linux/amd64 \
--tag fosrl/pangolin:ee-postgresql-latest \
--tag fosrl/pangolin:ee-postgresql-$(major_tag) \
--tag fosrl/pangolin:ee-postgresql-$(minor_tag) \
--tag fosrl/pangolin:ee-postgresql-$(tag) \
--push .
build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .

View File

@@ -35,19 +35,25 @@
</div>
<p align="center">
<strong>
Start testing Pangolin at <a href="https://pangolin.fossorial.io/auth/signup">pangolin.fossorial.io</a>
</strong>
</p>
Pangolin is a self-hosted tunneled reverse proxy server with identity and context aware access control, designed to easily expose and protect applications running anywhere. Pangolin acts as a central hub and connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports or requiring a VPN.
## Installation
Check out the [quick install guide](https://docs.digpangolin.com) for how to install and set up Pangolin.
Check out the [quick install guide](https://docs.digpangolin.com/self-host/quick-install) for how to install and set up Pangolin.
## Deployment Options
| <img width=500 /> | Description |
|-----------------|--------------|
| **Self-Host: Community Edition** | 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://github.com/fosrl/remote-note) 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
@@ -55,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}}

180
install/get-installer.sh Normal file
View File

@@ -0,0 +1,180 @@
#!/bin/bash
# Get installer - Cross-platform installation script
# Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/installer/refs/heads/main/get-installer.sh | bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# GitHub repository info
REPO="fosrl/pangolin"
GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest"
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Function to get latest version from GitHub API
get_latest_version() {
local latest_info
if command -v curl >/dev/null 2>&1; then
latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null)
elif command -v wget >/dev/null 2>&1; then
latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null)
else
print_error "Neither curl nor wget is available. Please install one of them." >&2
exit 1
fi
if [ -z "$latest_info" ]; then
print_error "Failed to fetch latest version information" >&2
exit 1
fi
# Extract version from JSON response (works without jq)
local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
if [ -z "$version" ]; then
print_error "Could not parse version from GitHub API response" >&2
exit 1
fi
# Remove 'v' prefix if present
version=$(echo "$version" | sed 's/^v//')
echo "$version"
}
# Detect OS and architecture
detect_platform() {
local os arch
# Detect OS - only support Linux
case "$(uname -s)" in
Linux*) os="linux" ;;
*)
print_error "Unsupported operating system: $(uname -s). Only Linux is supported."
exit 1
;;
esac
# Detect architecture - only support amd64 and arm64
case "$(uname -m)" in
x86_64|amd64) arch="amd64" ;;
arm64|aarch64) arch="arm64" ;;
*)
print_error "Unsupported architecture: $(uname -m). Only amd64 and arm64 are supported on Linux."
exit 1
;;
esac
echo "${os}_${arch}"
}
# Get installation directory
get_install_dir() {
# Install to the current directory
local install_dir="$(pwd)"
if [ ! -d "$install_dir" ]; then
print_error "Installation directory does not exist: $install_dir"
exit 1
fi
echo "$install_dir"
}
# Download and install installer
install_installer() {
local platform="$1"
local install_dir="$2"
local binary_name="installer_${platform}"
local download_url="${BASE_URL}/${binary_name}"
local temp_file="/tmp/installer"
local final_path="${install_dir}/installer"
print_status "Downloading installer from ${download_url}"
# Download the binary
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$download_url" -o "$temp_file"
elif command -v wget >/dev/null 2>&1; then
wget -q "$download_url" -O "$temp_file"
else
print_error "Neither curl nor wget is available. Please install one of them."
exit 1
fi
# Create install directory if it doesn't exist
mkdir -p "$install_dir"
# Move binary to install directory
mv "$temp_file" "$final_path"
# Make executable
chmod +x "$final_path"
print_status "Installer downloaded to ${final_path}"
}
# Verify installation
verify_installation() {
local install_dir="$1"
local installer_path="${install_dir}/installer"
if [ -f "$installer_path" ] && [ -x "$installer_path" ]; then
print_status "Installation successful!"
return 0
else
print_error "Installation failed. Binary not found or not executable."
return 1
fi
}
# Main installation process
main() {
print_status "Installing latest version of installer..."
# Get latest version
print_status "Fetching latest version from GitHub..."
VERSION=$(get_latest_version)
print_status "Latest version: v${VERSION}"
# Set base URL with the fetched version
BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}"
# Detect platform
PLATFORM=$(detect_platform)
print_status "Detected platform: ${PLATFORM}"
# Get install directory
INSTALL_DIR=$(get_install_dir)
print_status "Install directory: ${INSTALL_DIR}"
# Install installer
install_installer "$PLATFORM" "$INSTALL_DIR"
# Verify installation
if verify_installation "$INSTALL_DIR"; then
print_status "Installer is ready to use!"
else
exit 1
fi
}
# Run main function
main "$@"

View File

@@ -1839,7 +1839,7 @@
"companyPhoneNumber": "Company phone number",
"country": "Country",
"phoneNumberOptional": "Phone number (optional)",
"complianceConfirmation": "I confirm that I am in compliance with the Fossorial Commercial License and that reporting inaccurate information or misidentifying use of the product is a violation of the license."
"complianceConfirmation": "I confirm that the information I provided is accurate and that I am in compliance with the Fossorial Commercial License. Reporting inaccurate information or misidentifying use of the product is a violation of the license and may result in your key getting revoked."
},
"buttons": {
"close": "Close",
@@ -1893,5 +1893,6 @@
"pathRewriteExact": "Exact",
"pathRewriteRegex": "Regex",
"pathRewriteStrip": "Strip",
"pathRewriteStripLabel": "strip"
"pathRewriteStripLabel": "strip",
"sidebarEnableEnterpriseLicense": "Enable Enterprise License"
}

3784
package-lock.json generated

File diff suppressed because it is too large Load Diff

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

@@ -13,61 +13,21 @@
import config from "./config";
import { certificates, db } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decryptData } from "@server/lib/encryption";
import * as fs from "fs";
import NodeCache from "node-cache";
import logger from "@server/logger";
export async function getValidCertificatesForDomains(
domains: Set<string>
): Promise<
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
}>
> {
if (domains.size === 0) {
return [];
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
const domainArray = Array.from(domains);
// TODO: add more foreign keys to make this query more efficient - we dont need to keep getting every certificate
const validCerts = await db
.select({
id: certificates.certId,
domain: certificates.domain,
certFile: certificates.certFile,
keyFile: certificates.keyFile,
expiresAt: certificates.expiresAt,
updatedAt: certificates.updatedAt,
wildcard: certificates.wildcard
})
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile)
)
);
// Filter certificates for the specified domains and if it is a wildcard then you can match on everything up to the first dot
const validCertsFiltered = validCerts.filter((cert) => {
return (
domainArray.includes(cert.domain) ||
(cert.wildcard &&
domainArray.some((domain) =>
domain.endsWith(`.${cert.domain}`)
))
);
});
const encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
@@ -75,10 +35,164 @@ export async function getValidCertificatesForDomains(
);
}
const encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
const encryptionKey = Buffer.from(encryptionKeyHex, "hex");
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
const validCertsDecrypted = validCertsFiltered.map((cert) => {
// Define the return type for clarity and type safety
export type CertificateResult = {
id: number;
domain: string;
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
};
// --- In-Memory Cache Implementation ---
const certificateCache = new NodeCache({ stdTTL: 180 }); // Cache for 3 minutes (180 seconds)
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>();
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cachedCert = certificateCache.get<CertificateResult>(domain);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
domainsToQuery.add(domain); // Cache miss or expired
}
}
} else {
// If caching is disabled, add all domains to the query set
domains.forEach((d) => domainsToQuery.add(d));
}
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults;
}
// 3. Prepare domains for the database query
const domainsToQueryArray = Array.from(domainsToQuery);
const parentDomainsToQuery = new Set<string>();
domainsToQueryArray.forEach((domain) => {
const parts = domain.split(".");
// A wildcard can only match a domain with at least two parts (e.g., example.com)
if (parts.length > 1) {
parentDomainsToQuery.add(parts.slice(1).join("."));
}
});
const parentDomainsArray = Array.from(parentDomainsToQuery);
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
.select()
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile),
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, parentDomainsArray),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
sql`false`
)
)
);
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
wildcardMatches.set(cert.domain, cert);
} else {
exactMatches.set(cert.domain, cert);
}
}
for (const domain of domainsToQuery) {
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
// Priority 1: Check for an exact match (non-wildcard)
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate that matches the exact domain
else {
if (wildcardMatches.has(domain)) {
foundCert = wildcardMatches.get(domain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = domain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
foundCert = wildcardMatches.get(parentDomain);
}
}
}
}
// 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}`
);
const resultCert: CertificateResult = {
id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record
queriedDomain: domain, // The domain that was originally requested
wildcard: foundCert.wildcard,
certFile: foundCert.certFile,
keyFile: foundCert.keyFile,
expiresAt: foundCert.expiresAt,
updatedAt: foundCert.updatedAt
};
finalResults.push(resultCert);
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
certificateCache.set(domain, resultCert);
}
}
}
const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults;
}
function decryptFinalResults(
finalResults: CertificateResult[]
): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decryptData(
cert.certFile!, // is not null from query
@@ -97,4 +211,4 @@ export async function getValidCertificatesForDomains(
});
return validCertsDecrypted;
}
}

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

@@ -183,47 +183,47 @@ export async function listExitNodes(orgId: string, filterOnline = false, noCloud
return [];
}
// Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails
const nodesWithRealOnlineStatus = await Promise.all(
allExitNodes.map(async (node) => {
// If database says it's online, verify with HTTP ping
let online: boolean;
if (filterOnline && node.type == "remoteExitNode") {
try {
const isActuallyOnline = await checkExitNodeOnlineStatus(
node.endpoint
);
// // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails
// const nodesWithRealOnlineStatus = await Promise.all(
// allExitNodes.map(async (node) => {
// // If database says it's online, verify with HTTP ping
// let online: boolean;
// if (filterOnline && node.type == "remoteExitNode") {
// try {
// const isActuallyOnline = await checkExitNodeOnlineStatus(
// node.endpoint
// );
// set the item in the database if it is offline
if (isActuallyOnline != node.online) {
await db
.update(exitNodes)
.set({ online: isActuallyOnline })
.where(eq(exitNodes.exitNodeId, node.exitNodeId));
}
online = isActuallyOnline;
} catch (error) {
logger.warn(
`Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}`
);
online = false;
}
} else {
online = node.online;
}
// // set the item in the database if it is offline
// if (isActuallyOnline != node.online) {
// await db
// .update(exitNodes)
// .set({ online: isActuallyOnline })
// .where(eq(exitNodes.exitNodeId, node.exitNodeId));
// }
// online = isActuallyOnline;
// } catch (error) {
// logger.warn(
// `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}`
// );
// online = false;
// }
// } else {
// online = node.online;
// }
return {
...node,
online
};
})
);
// return {
// ...node,
// online
// };
// })
// );
const remoteExitNodes = nodesWithRealOnlineStatus.filter(
const remoteExitNodes = allExitNodes.filter(
(node) =>
node.type === "remoteExitNode" && (!filterOnline || node.online)
);
const gerbilExitNodes = nodesWithRealOnlineStatus.filter(
const gerbilExitNodes = allExitNodes.filter(
(node) => node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
);

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,13 +19,27 @@ 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";
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
import privateConfig from "#private/lib/config";
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
import {
CertificateResult,
getValidCertificatesForDomains
} from "#private/lib/certificates";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -89,14 +103,11 @@ export async function getTraefikConfig(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
// Namespace
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Certificate
certificateStatus: certificates.status
domainNamespaceId: domainNamespaces.domainNamespaceId
})
.from(sites)
.innerJoin(targets, eq(targets.siteId, sites.siteId))
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
@@ -109,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)
@@ -183,7 +198,6 @@ export async function getTraefikConfig(
tlsServerName: row.tlsServerName,
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
certificateStatus: row.certificateStatus,
targets: [],
headers: row.headers,
path: row.path, // the targets will all have the same path
@@ -213,6 +227,20 @@ export async function getTraefikConfig(
});
});
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.enabled && resource.ssl && resource.fullDomain) {
domains.add(resource.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
}
const config_output: any = {
http: {
middlewares: {
@@ -255,14 +283,6 @@ export async function getTraefikConfig(
continue;
}
// TODO: for now dont filter it out because if you have multiple domain ids and one is failed it causes all of them to fail
// if (resource.certificateStatus !== "valid" && privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// logger.debug(
// `Resource ${resource.resourceId} has certificate stats ${resource.certificateStats}`
// );
// continue;
// }
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
@@ -272,22 +292,22 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
let certResolver: string, preferWildcardCert: boolean;
if (!configDomain) {
certResolver = config.getRawConfig().traefik.cert_resolver;
@@ -310,6 +330,17 @@ export async function getTraefikConfig(
}
: {})
};
} else {
// find a cert that matches the full domain, if not continue
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === resource.fullDomain
);
if (!matchingCert) {
logger.warn(
`No matching certificate found for domain: ${resource.fullDomain}`
);
continue;
}
}
const additionalMiddlewares =
@@ -676,20 +707,31 @@ export async function getTraefikConfig(
loginPageId: loginPage.loginPageId,
fullDomain: loginPage.fullDomain,
exitNodeId: exitNodes.exitNodeId,
domainId: loginPage.domainId,
certificateStatus: certificates.status
domainId: loginPage.domainId
})
.from(loginPage)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, loginPage.exitNodeId)
)
.leftJoin(
certificates,
eq(certificates.domainId, loginPage.domainId)
)
.where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const lp of exitNodeLoginPages) {
if (lp.fullDomain) {
domains.add(lp.fullDomain);
}
}
// get the valid certs for these domains
validCertsLoginPages = await getValidCertificatesForDomains(
domains,
true
); // we are caching here because this is called often
}
if (exitNodeLoginPages.length > 0) {
if (!config_output.http.services) {
config_output.http.services = {};
@@ -719,8 +761,22 @@ export async function getTraefikConfig(
continue;
}
if (lp.certificateStatus !== "valid") {
continue;
let tls = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
// TODO: we need to add the wildcard logic here too
} else {
// find a cert that matches the full domain, if not continue
const matchingCert = validCertsLoginPages.find(
(cert) => cert.queriedDomain === lp.fullDomain
);
if (!matchingCert) {
logger.warn(
`No matching certificate found for login page domain: ${lp.fullDomain}`
);
continue;
}
}
// auth-allowed:
@@ -743,7 +799,7 @@ export async function getTraefikConfig(
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 203,
tls: {}
tls: tls
};
// auth-catchall:
@@ -762,7 +818,7 @@ export async function getTraefikConfig(
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
priority: 202,
tls: {}
tls: tls
};
// we need to add a redirect from http to https too

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">

File diff suppressed because it is too large Load Diff

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";