mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-30 14:50:45 +00:00
Compare commits
36 Commits
1.11.0-s.3
...
1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af1e0ef56 | ||
|
|
08b7d6735c | ||
|
|
a91ebd1e91 | ||
|
|
312e03b4eb | ||
|
|
e8a57e432c | ||
|
|
bca2eef2e8 | ||
|
|
ec7211a15d | ||
|
|
46807c6477 | ||
|
|
b578786e62 | ||
|
|
2e0ad8d262 | ||
|
|
003f0cfa6d | ||
|
|
ee3df081ef | ||
|
|
08eeb12519 | ||
|
|
e66c6b2505 | ||
|
|
d2a880d9c8 | ||
|
|
edc0b86470 | ||
|
|
aebe6b80b7 | ||
|
|
4d87333b43 | ||
|
|
ef32f3ed5a | ||
|
|
216ded3034 | ||
|
|
cb59fe2cee | ||
|
|
7776f6d09c | ||
|
|
c50392c947 | ||
|
|
ceee978fcd | ||
|
|
c5a73dc87e | ||
|
|
7198ef2774 | ||
|
|
7e9a066797 | ||
|
|
ba96332313 | ||
|
|
e2d0338b0b | ||
|
|
59ecab5738 | ||
|
|
721bf3403d | ||
|
|
3b8ba47377 | ||
|
|
e752929f69 | ||
|
|
e41c3e6f54 | ||
|
|
9dedd1a8de | ||
|
|
c4a5fae28f |
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Build and Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: amd64-runner
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
22
Makefile
22
Makefile
@@ -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 .
|
||||
|
||||
20
README.md
20
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
180
install/get-installer.sh
Normal 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 "$@"
|
||||
@@ -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
3784
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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" && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user