Compare commits

..

6 Commits

Author SHA1 Message Date
Owen Schwartz
3436105bec Merge pull request #2784 from fosrl/dev
Try to prevent deadlocks
2026-04-03 23:01:09 -04:00
Owen Schwartz
4b3375ab8e Merge pull request #2783 from fosrl/dev
Fix 1.17.0
2026-04-03 22:42:03 -04:00
Owen Schwartz
6ce165bfd5 Merge pull request #2780 from fosrl/dev
1.17.0
2026-04-03 18:19:40 -04:00
Owen Schwartz
035644eaf7 Merge pull request #2778 from fosrl/dev
1.17.0-s.2
2026-04-03 12:35:03 -04:00
Owen Schwartz
16e7233a3e Merge pull request #2777 from fosrl/dev
1.17.0-s.1
2026-04-03 12:19:23 -04:00
Owen Schwartz
1f74e1b320 Merge pull request #2776 from fosrl/dev
1.17.0-s.0
2026-04-03 11:39:35 -04:00
25 changed files with 524 additions and 1607 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @oschwartz10612 @miloschwartz

View File

@@ -1817,11 +1817,6 @@
"editInternalResourceDialogModePort": "Port",
"editInternalResourceDialogModeHost": "Host",
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable SSL",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
@@ -1865,19 +1860,11 @@
"createInternalResourceDialogModePort": "Port",
"createInternalResourceDialogModeHost": "Host",
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable SSL",
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
"createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
"siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
@@ -2129,7 +2116,6 @@
"domainPickerFreeProvidedDomain": "Free Provided Domain",
"domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains",
@@ -2673,12 +2659,8 @@
"editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.",
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP",
@@ -2717,8 +2699,6 @@
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
"maintenancePageMessageDescription": "Detailed message explaining the maintenance",
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
"editDomain": "Edit Domain",

View File

@@ -57,9 +57,7 @@ export const orgs = pgTable("orgs", {
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysConnection: integer(
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -103,9 +101,7 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
status: varchar("status").$type<"pending" | "approved">().default("approved")
});
export const resources = pgTable("resources", {
@@ -234,9 +230,8 @@ export const siteResources = pgTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
protocol: varchar("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode

View File

@@ -54,9 +54,7 @@ export const orgs = sqliteTable("orgs", {
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysConnection: integer(
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -260,9 +258,8 @@ export const siteResources = sqliteTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
protocol: text("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname

View File

@@ -22,7 +22,6 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
async function startServers() {
@@ -40,7 +39,6 @@ async function startServers() {
initTelemetryClient();
initLogCleanupInterval();
initAcmeCertSync();
// Start all servers
const apiServer = createApiServer();

View File

@@ -1,3 +0,0 @@
export function initAcmeCertSync(): void {
// stub
}

View File

@@ -16,20 +16,6 @@ import { Config } from "./types";
import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip";
function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): {
mode: "host" | "cidr" | "http";
ssl: boolean;
scheme: "http" | "https" | null;
} {
if (mode === "https") {
return { mode: "http", ssl: true, scheme: "https" };
}
if (mode === "http") {
return { mode: "http", ssl: false, scheme: "http" };
}
return { mode, ssl: false, scheme: null };
}
export type ClientResourcesResults = {
newSiteResource: SiteResource;
oldSiteResource?: SiteResource;
@@ -90,18 +76,14 @@ export async function updateClientResources(
}
if (existingResource) {
const mappedMode = siteResourceModeForDb(resourceData.mode);
// Update existing resource
const [updatedResource] = await trx
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: mappedMode.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
mode: resourceData.mode,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
@@ -225,9 +207,9 @@ export async function updateClientResources(
oldSiteResource: existingResource
});
} else {
const mappedMode = siteResourceModeForDb(resourceData.mode);
let aliasAddress: string | null = null;
if (mappedMode.mode === "host" || mappedMode.mode === "http") {
if (resourceData.mode == "host") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
@@ -239,11 +221,8 @@ export async function updateClientResources(
siteId: site.siteId,
niceId: resourceNiceId,
name: resourceData.name || resourceNiceId,
mode: mappedMode.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
mode: resourceData.mode,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,

View File

@@ -325,11 +325,11 @@ export function isTargetsOnlyResource(resource: any): boolean {
export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http", "https"]),
mode: z.enum(["host", "cidr"]),
site: z.string(),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
// enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),

View File

@@ -582,16 +582,6 @@ export type SubnetProxyTargetV2 = {
protocol: "tcp" | "udp";
}[];
resourceId?: number;
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
httpTargets?: HTTPTarget[];
tlsCert?: string;
tlsKey?: string;
};
export type HTTPTarget = {
destAddr: string; // must be an IP or hostname
destPort: number;
scheme: "http" | "https";
};
export function generateSubnetProxyTargetV2(
@@ -629,7 +619,7 @@ export function generateSubnetProxyTargetV2(
destPrefix: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
resourceId: siteResource.siteResourceId,
};
}
@@ -641,7 +631,7 @@ export function generateSubnetProxyTargetV2(
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
resourceId: siteResource.siteResourceId,
};
}
} else if (siteResource.mode == "cidr") {
@@ -650,46 +640,7 @@ export function generateSubnetProxyTargetV2(
destPrefix: siteResource.destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
};
} else if (siteResource.mode == "http") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`;
}
if (
!siteResource.alias ||
!siteResource.aliasAddress ||
!siteResource.destinationPort ||
!siteResource.scheme
) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
);
return;
}
const publicProtocol = siteResource.ssl ? "https" : "http";
// also push a match for the alias address
target = {
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId,
protocol: publicProtocol,
httpTargets: [
{
destAddr: siteResource.destination,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
]
// tlsCert: "",
// tlsKey: ""
};
}
@@ -719,6 +670,7 @@ export function generateSubnetProxyTargetV2(
return target;
}
/**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry.
@@ -745,6 +697,7 @@ export function convertSubnetProxyTargetsV2ToV1(
);
}
// Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z

View File

@@ -1,277 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import fs from "fs";
import crypto from "crypto";
import { certificates, domains, db } from "@server/db";
import { and, eq } from "drizzle-orm";
import { encryptData, decryptData } from "@server/lib/encryption";
import logger from "@server/logger";
import config from "#private/lib/config";
interface AcmeCert {
domain: { main: string; sans?: string[] };
certificate: string;
key: string;
Store: string;
}
interface AcmeJson {
[resolver: string]: {
Certificates: AcmeCert[];
};
}
function getEncryptionKey(): Buffer {
const keyHex = config.getRawPrivateConfig().server.encryption_key;
if (!keyHex) {
throw new Error("acmeCertSync: encryption key is not configured");
}
return Buffer.from(keyHex, "hex");
}
async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.")
? certDomain.slice(2)
: certDomain;
// 1. Exact baseDomain match (any domain type)
const exactMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(eq(domains.baseDomain, lookupDomain))
.limit(1);
if (exactMatch.length > 0) {
return exactMatch[0].domainId;
}
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
// matches a wildcard domain with baseDomain "example.com".
const parts = lookupDomain.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
if (!candidate) continue;
const wildcardMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(
and(
eq(domains.baseDomain, candidate),
eq(domains.type, "wildcard")
)
)
.limit(1);
if (wildcardMatch.length > 0) {
return wildcardMatch[0].domainId;
}
}
return null;
}
function extractFirstCert(pemBundle: string): string | null {
const match = pemBundle.match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
);
return match ? match[0] : null;
}
async function syncAcmeCerts(
acmeJsonPath: string,
resolver: string
): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.debug(
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
);
return;
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
return;
}
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
return;
}
const encryptionKey = getEncryptionKey();
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
if (!domain) {
logger.debug(
`acmeCertSync: skipping cert with missing domain`
);
continue;
}
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
);
continue;
}
const certPem = Buffer.from(cert.certificate, "base64").toString(
"utf8"
);
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
);
continue;
}
// Check if cert already exists in DB
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decryptData(
existing[0].certFile,
encryptionKey
);
if (storedCertPem === certPem) {
logger.debug(
`acmeCertSync: cert for ${domain} is unchanged, skipping`
);
continue;
}
} catch (err) {
// Decryption failure means we should proceed with the update
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
// Parse cert expiry from the first cert in the PEM bundle
let expiresAt: number | null = null;
const firstCertPem = extractFirstCert(certPem);
if (firstCertPem) {
try {
const x509 = new crypto.X509Certificate(firstCertPem);
expiresAt = Math.floor(
new Date(x509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
}
const wildcard = domain.startsWith("*.");
const encryptedCert = encryptData(certPem, encryptionKey);
const encryptedKey = encryptData(keyPem, encryptionKey);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
);
}
if (existing.length > 0) {
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.info(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
} else {
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.info(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
}
}
}
export function initAcmeCertSync(): void {
const privateConfig = config.getRawPrivateConfig();
if (!privateConfig.flags?.enable_acme_cert_sync) {
return;
}
const acmeJsonPath =
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
logger.info(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
);
// Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
setInterval(() => {
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
}

View File

@@ -95,21 +95,10 @@ export const privateConfigSchema = z.object({
.object({
enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(false)
use_org_only_idp: z.boolean().optional()
})
.optional()
.prefault({}),
acme: z
.object({
acme_json_path: z
.string()
.optional()
.default("config/letsencrypt/acme.json"),
resolver: z.string().optional().default("letsencrypt"),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
branding: z
.object({
app_name: z.string().optional(),

View File

@@ -33,7 +33,7 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, siteResources, Target, targets } from "@server/db";
import { orgs, resources, sites, Target, targets } from "@server/db";
import {
sanitize,
encodePath,
@@ -267,34 +267,6 @@ export async function getTraefikConfig(
});
});
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
const siteResourcesWithAliases = await db
.select({
siteResourceId: siteResources.siteResourceId,
alias: siteResources.alias,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(sites, eq(sites.siteId, siteResources.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.alias),
eq(siteResources.mode, "http"),
eq(siteResources.ssl, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for
@@ -304,12 +276,6 @@ export async function getTraefikConfig(
domains.add(resource.fullDomain);
}
}
// Include siteResource aliases so pangolin-dns also fetches certs for them
for (const sr of siteResourcesWithAliases) {
if (sr.alias) {
domains.add(sr.alias);
}
}
// 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)}`);
@@ -901,128 +867,6 @@ export async function getTraefikConfig(
}
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
if (siteResourcesWithAliases.length > 0) {
// Build a set of domains already covered by normal resources
const existingFullDomains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.fullDomain) {
existingFullDomains.add(resource.fullDomain);
}
}
for (const sr of siteResourcesWithAliases) {
if (!sr.alias) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.alias)) continue;
const alias = sr.alias;
const srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`;
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (!config_output.http.services) {
config_output.http.services = {};
}
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// Service pointing at the internal maintenance/Next.js page
config_output.http.services[siteResourceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
}
],
passHostHeader: true
}
};
// Middleware that rewrites any path to /maintenance-screen
config_output.http.middlewares[
siteResourceRewriteMiddlewareName
] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
// HTTP -> HTTPS redirect so the ACME challenge can be served
config_output.http.routers[
`${siteResourceRouterName}-redirect`
] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${alias}\`)`,
priority: 100
};
// Determine TLS / cert-resolver configuration
let tls: any = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
const domainParts = alias.split(".");
const wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else {
// pangolin-dns: only add route if we already have a valid cert
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === alias
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for siteResource alias: ${alias}`
);
continue;
}
}
// HTTPS router — presence of this entry triggers cert generation
config_output.http.routers[siteResourceRouterName] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${alias}\`)`,
priority: 100,
tls
};
}
}
if (generateLoginPageRouters) {
const exitNodeLoginPages = await db
.select({

View File

@@ -144,7 +144,7 @@ export async function getUserResources(
name: string;
destination: string;
mode: string;
scheme: string | null;
protocol: string | null;
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
@@ -156,7 +156,7 @@ export async function getUserResources(
name: siteResources.name,
destination: siteResources.destination,
mode: siteResources.mode,
scheme: siteResources.scheme,
protocol: siteResources.protocol,
enabled: siteResources.enabled,
alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress
@@ -240,7 +240,7 @@ export async function getUserResources(
name: siteResource.name,
destination: siteResource.destination,
mode: siteResource.mode,
protocol: siteResource.scheme,
protocol: siteResource.protocol,
enabled: siteResource.enabled,
alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress,
@@ -289,7 +289,7 @@ export type GetUserResourcesResponse = {
enabled: boolean;
alias: string | null;
aliasAddress: string | null;
type: "site";
type: 'site';
}>;
};
};

View File

@@ -36,12 +36,11 @@ const createSiteResourceParamsSchema = z.strictObject({
const createSiteResourceSchema = z
.strictObject({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
ssl: z.boolean().optional(), // only used for http mode
mode: z.enum(["host", "cidr", "port"]),
siteId: z.int(),
scheme: z.enum(["http", "https"]).optional(),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(),
destination: z.string().min(1),
enabled: z.boolean().default(true),
alias: z
@@ -63,11 +62,7 @@ const createSiteResourceSchema = z
.strict()
.refine(
(data) => {
if (
data.mode === "host" ||
data.mode == "http"
) {
if (data.mode == "host") {
if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
// .union([z.ipv4(), z.ipv6()])
@@ -77,7 +72,6 @@ const createSiteResourceSchema = z
if (isValidIP) {
return true;
}
}
// Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex =
@@ -111,21 +105,6 @@ const createSiteResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.destinationPort !== undefined &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
);
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -182,12 +161,11 @@ export async function createSiteResource(
name,
siteId,
mode,
scheme,
// protocol,
// proxyPort,
destinationPort,
// destinationPort,
destination,
enabled,
ssl,
alias,
userIds,
roleIds,
@@ -248,6 +226,30 @@ export async function createSiteResource(
);
}
// // check if resource with same protocol and proxy port already exists (only for port mode)
// if (mode === "port" && protocol && proxyPort) {
// const [existingResource] = await db
// .select()
// .from(siteResources)
// .where(
// and(
// eq(siteResources.siteId, siteId),
// eq(siteResources.orgId, orgId),
// eq(siteResources.protocol, protocol),
// eq(siteResources.proxyPort, proxyPort)
// )
// )
// .limit(1);
// if (existingResource && existingResource.siteResourceId) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// "A resource with the same protocol and proxy port already exists"
// )
// );
// }
// }
// make sure the alias is unique within the org if provided
if (alias) {
const [conflict] = await db
@@ -278,7 +280,8 @@ export async function createSiteResource(
const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null;
if (mode === "host" || mode === "http") {
if (mode == "host") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
@@ -290,11 +293,8 @@ export async function createSiteResource(
niceId,
orgId,
name,
mode,
ssl,
mode: mode as "host" | "cidr",
destination,
scheme,
destinationPort,
enabled,
alias,
aliasAddress,

View File

@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
}),
query: z.string().optional(),
mode: z
.enum(["host", "cidr", "http"])
.enum(["host", "cidr"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["host", "cidr", "http"],
enum: ["host", "cidr"],
description: "Filter site resources by mode"
}),
sort_by: z
@@ -88,8 +88,7 @@ function querySiteResourcesBase() {
niceId: siteResources.niceId,
name: siteResources.name,
mode: siteResources.mode,
ssl: siteResources.ssl,
scheme: siteResources.scheme,
protocol: siteResources.protocol,
proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort,
destination: siteResources.destination,
@@ -194,9 +193,7 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
querySiteResourcesBase()
.where(and(...conditions))
.as("filtered_site_resources")
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
);
const [siteResourcesList, totalCount] = await Promise.all([

View File

@@ -51,11 +51,10 @@ const updateSiteResourceSchema = z
)
.optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http"]).optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(),
mode: z.enum(["host", "cidr"]).optional(),
// protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().nullish(),
destinationPort: z.int().positive().nullish(),
// destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(),
enabled: z.boolean().optional(),
alias: z
@@ -77,12 +76,7 @@ const updateSiteResourceSchema = z
.strict()
.refine(
(data) => {
if (
(data.mode === "host" ||
data.mode == "http") &&
data.destination
) {
if (data.mode == "host") {
if (data.mode === "host" && data.destination) {
const isValidIP = z
// .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
@@ -91,7 +85,6 @@ const updateSiteResourceSchema = z
if (isValidIP) {
return true;
}
}
// Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex =
@@ -125,23 +118,6 @@ const updateSiteResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
)
.refine(
(data) => {
if (data.mode !== "http") return true;
return (
data.scheme !== undefined &&
data.scheme !== null &&
data.destinationPort !== undefined &&
data.destinationPort !== null &&
data.destinationPort >= 1 &&
data.destinationPort <= 65535
);
},
{
message:
"HTTP mode requires scheme (http or https) and a valid destination port"
}
);
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -199,11 +175,8 @@ export async function updateSiteResource(
siteId, // because it can change
niceId,
mode,
scheme,
destination,
destinationPort,
alias,
ssl,
enabled,
userIds,
roleIds,
@@ -373,10 +346,7 @@ export async function updateSiteResource(
siteId,
niceId,
mode,
scheme,
ssl,
destination,
destinationPort,
enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString,
@@ -479,10 +449,7 @@ export async function updateSiteResource(
name: name,
siteId: siteId,
mode: mode,
scheme,
ssl,
destination: destination,
destinationPort: destinationPort,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,

View File

@@ -235,9 +235,7 @@ export default async function migration() {
for (const row of existingUserInviteRoles) {
await db.execute(sql`
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
SELECT ${row.inviteId}, ${row.roleId}
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
VALUES (${row.inviteId}, ${row.roleId})
ON CONFLICT DO NOTHING
`);
}
@@ -260,10 +258,7 @@ export default async function migration() {
for (const row of existingUserOrgRoles) {
await db.execute(sql`
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
ON CONFLICT DO NOTHING
`);
}

View File

@@ -145,7 +145,7 @@ export default async function migration() {
).run();
db.prepare(
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
).run();
db.prepare(`DROP TABLE 'userOrgs';`).run();
db.prepare(
@@ -246,15 +246,12 @@ export default async function migration() {
// Re-insert the preserved invite role assignments into the new userInviteRoles table
if (existingUserInviteRoles.length > 0) {
const insertUserInviteRole = db.prepare(
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
SELECT ?, ?
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
);
const insertAll = db.transaction(() => {
for (const row of existingUserInviteRoles) {
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
insertUserInviteRole.run(row.inviteId, row.roleId);
}
});
@@ -268,16 +265,12 @@ export default async function migration() {
// Re-insert the preserved role assignments into the new userOrgRoles table
if (existingUserOrgRoles.length > 0) {
const insertUserOrgRole = db.prepare(
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
SELECT ?, ?, ?
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
);
const insertAll = db.transaction(() => {
for (const row of existingUserOrgRoles) {
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
}
});

View File

@@ -56,30 +56,18 @@ export default async function ClientResourcesPage(
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
const rawMode = siteResource.mode as string | undefined;
const normalizedMode =
rawMode === "https"
? ("http" as const)
: rawMode === "host" || rawMode === "cidr" || rawMode === "http"
? rawMode
: ("host" as const);
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: normalizedMode,
scheme:
siteResource.scheme ??
(rawMode === "https" ? ("https" as const) : null),
ssl:
siteResource.ssl === true || rawMode === "https",
mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destination: siteResource.destination,
httpHttpsPort: siteResource.destinationPort ?? null,
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId,

View File

@@ -1,32 +0,0 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Private Placeholder"
};
export default async function MaintenanceScreen() {
const t = await getTranslations();
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{message}</CardContent>
</Card>
</div>
);
}

View File

@@ -46,15 +46,13 @@ export type InternalResourceRow = {
siteName: string;
siteAddress: string | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http";
scheme: "http" | "https" | null;
ssl: boolean;
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string;
httpHttpsPort: number | null;
// destinationPort: number | null;
alias: string | null;
aliasAddress: string | null;
niceId: string;
@@ -65,39 +63,6 @@ export type InternalResourceRow = {
authDaemonPort?: number | null;
};
function resolveHttpHttpsDisplayPort(
mode: "http",
httpHttpsPort: number | null
): number {
if (httpHttpsPort != null) {
return httpHttpsPort;
}
return 80;
}
function formatDestinationDisplay(row: InternalResourceRow): string {
const { mode, destination, httpHttpsPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
}
function isSafeUrlForLink(href: string): boolean {
try {
void new URL(href);
return true;
} catch {
return false;
}
}
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
@@ -250,10 +215,6 @@ export default function ClientResourcesTable({
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
@@ -266,14 +227,10 @@ export default function ClientResourcesTable({
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
string
> = {
const modeLabels: Record<"host" | "cidr" | "port", string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
port: t("editInternalResourceDialogModePort")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
@@ -286,12 +243,11 @@ export default function ClientResourcesTable({
),
cell: ({ row }) => {
const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return (
<CopyToClipboard
text={display}
text={resourceRow.destination}
isLink={false}
displayText={display}
displayText={resourceRow.destination}
/>
);
}
@@ -304,27 +260,16 @@ export default function ClientResourcesTable({
),
cell: ({ row }) => {
const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) {
return (
return resourceRow.mode === "host" && resourceRow.alias ? (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
) : (
<span>-</span>
);
}
if (resourceRow.mode === "http" && resourceRow.alias) {
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`;
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
);
}
return <span>-</span>;
}
},
{
accessorKey: "aliasAddress",

View File

@@ -50,10 +50,7 @@ export default function CreateInternalResourceDialog({
setIsSubmitting(true);
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
if (data.mode === "host" && isHostname(data.destination)) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
@@ -72,42 +69,21 @@ export default function CreateInternalResourceDialog({
mode: data.mode,
destination: data.destination,
enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? undefined
}),
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
}),
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients
? data.clients.map((c) => parseInt(c.id))
: []
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
}
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
variant: "default"
});
setOpen(false);
@@ -117,9 +93,7 @@ export default function CreateInternalResourceDialog({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
t("createInternalResourceDialogFailedToCreateInternalResource")
),
variant: "destructive"
});
@@ -132,13 +106,9 @@ export default function CreateInternalResourceDialog({
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
<CredenzaDescription>
{t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
{t("createInternalResourceDialogCreateClientResourceDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
@@ -153,11 +123,7 @@ export default function CreateInternalResourceDialog({
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("createInternalResourceDialogCancel")}
</Button>
</CredenzaClose>

View File

@@ -163,18 +163,15 @@ export default function DomainPicker({
domainId: firstOrExistingDomain.domainId
};
const base = firstOrExistingDomain.baseDomain;
const sub =
firstOrExistingDomain.type !== "cname"
? defaultSubdomain?.trim() || undefined
: undefined;
onDomainChange?.({
domainId: firstOrExistingDomain.domainId,
type: "organization",
subdomain: sub,
fullDomain: sub ? `${sub}.${base}` : base,
baseDomain: base
subdomain:
firstOrExistingDomain.type !== "cname"
? defaultSubdomain || undefined
: undefined,
fullDomain: firstOrExistingDomain.baseDomain,
baseDomain: firstOrExistingDomain.baseDomain
});
}
}
@@ -512,9 +509,7 @@ export default function DomainPicker({
<span className="truncate">
{selectedBaseDomain.domain}
</span>
{selectedBaseDomain.verified &&
selectedBaseDomain.domainType !==
"wildcard" && (
{selectedBaseDomain.verified && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
</div>
@@ -579,13 +574,6 @@ export default function DomainPicker({
}
</span>
<span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: (
<>
{orgDomain.type.toUpperCase()}{" "}
{" "}
{orgDomain.verified
@@ -595,8 +583,6 @@ export default function DomainPicker({
: t(
"domainPickerUnverified"
)}
</>
)}
</span>
</div>
<Check

View File

@@ -54,10 +54,7 @@ export default function EditInternalResourceDialog({
async function handleSubmit(values: InternalResourceFormValues) {
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
if (data.mode === "host" && isHostname(data.destination)) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination;
@@ -74,11 +71,6 @@ export default function EditInternalResourceDialog({
mode: data.mode,
niceId: data.niceId,
destination: data.destination,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null
}),
alias:
data.alias &&
typeof data.alias === "string" &&

View File

@@ -1,10 +1,6 @@
"use client";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { StrategySelect } from "@app/components/StrategySelect";
import { Tag, TagInput } from "@app/components/tags/tag-input";
@@ -49,8 +45,6 @@ import { z } from "zod";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
// --- Helpers (shared) ---
@@ -126,14 +120,12 @@ export const cleanForFQDN = (name: string): string =>
type Site = ListSitesResponse["sites"][0];
export type InternalResourceMode = "host" | "cidr" | "http";
export type InternalResourceData = {
id: number;
name: string;
orgId: string;
siteName: string;
mode: InternalResourceMode;
mode: "host" | "cidr";
siteId: number;
niceId: string;
destination: string;
@@ -143,12 +135,6 @@ export type InternalResourceData = {
disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https" | null;
ssl?: boolean;
httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null;
};
const tagSchema = z.object({ id: z.string(), text: z.string() });
@@ -156,7 +142,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() });
export type InternalResourceFormValues = {
name: string;
siteId: number;
mode: InternalResourceMode;
mode: "host" | "cidr";
destination: string;
alias?: string | null;
niceId?: string;
@@ -165,12 +151,6 @@ export type InternalResourceFormValues = {
disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https";
ssl?: boolean;
httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null;
roles?: z.infer<typeof tagSchema>[];
users?: z.infer<typeof tagSchema>[];
clients?: z.infer<typeof tagSchema>[];
@@ -231,22 +211,6 @@ export function InternalResourceForm({
variant === "create"
? "createInternalResourceDialogModeCidr"
: "editInternalResourceDialogModeCidr";
const modeHttpKey =
variant === "create"
? "createInternalResourceDialogModeHttp"
: "editInternalResourceDialogModeHttp";
const schemeLabelKey =
variant === "create"
? "createInternalResourceDialogScheme"
: "editInternalResourceDialogScheme";
const enableSslLabelKey =
variant === "create"
? "createInternalResourceDialogEnableSsl"
: "editInternalResourceDialogEnableSsl";
const enableSslDescriptionKey =
variant === "create"
? "createInternalResourceDialogEnableSslDescription"
: "editInternalResourceDialogEnableSslDescription";
const destinationLabelKey =
variant === "create"
? "createInternalResourceDialogDestination"
@@ -259,27 +223,14 @@ export function InternalResourceForm({
variant === "create"
? "createInternalResourceDialogAlias"
: "editInternalResourceDialogAlias";
const httpHttpsPortLabelKey =
variant === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const httpConfigurationTitleKey =
variant === "create"
? "createInternalResourceDialogHttpConfiguration"
: "editInternalResourceDialogHttpConfiguration";
const httpConfigurationDescriptionKey =
variant === "create"
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const formSchema = z
.object({
const formSchema = z.object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteId: z
.number()
.int()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
mode: z.enum(["host", "cidr", "http"]),
mode: z.enum(["host", "cidr"]),
destination: z
.string()
.min(
@@ -289,18 +240,6 @@ export function InternalResourceForm({
: undefined
),
alias: z.string().nullish(),
httpHttpsPort: z
.number()
.int()
.min(1)
.max(65535)
.optional()
.nullable(),
scheme: z.enum(["http", "https"]).optional(),
ssl: z.boolean().optional(),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish(),
niceId: z
.string()
.min(1)
@@ -322,27 +261,6 @@ export function InternalResourceForm({
})
)
.optional()
})
.superRefine((data, ctx) => {
if (data.mode !== "http") return;
if (!data.scheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceDownstreamSchemeRequired"),
path: ["scheme"]
});
}
if (
data.httpHttpsPort == null ||
!Number.isFinite(data.httpHttpsPort) ||
data.httpHttpsPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["httpHttpsPort"]
});
}
});
type FormData = z.infer<typeof formSchema>;
@@ -476,12 +394,6 @@ export function InternalResourceForm({
disableIcmp: resource.disableIcmp ?? false,
authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonPort: resource.authDaemonPort ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
niceId: resource.niceId,
roles: [],
users: [],
@@ -493,12 +405,6 @@ export function InternalResourceForm({
mode: "host",
destination: "",
alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
@@ -519,10 +425,6 @@ export function InternalResourceForm({
});
const mode = form.watch("mode");
const httpConfigSubdomain = form.watch("httpConfigSubdomain");
const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpMode = mode === "http";
const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null);
@@ -546,12 +448,6 @@ export function InternalResourceForm({
mode: "host",
destination: "",
alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
@@ -579,12 +475,6 @@ export function InternalResourceForm({
mode: resource.mode ?? "host",
destination: resource.destination ?? "",
alias: resource.alias ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
@@ -691,50 +581,12 @@ export function InternalResourceForm({
)}
/>
)}
</div>
<HorizontalTabs
clientSide
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
...(disableEnterpriseFeatures || mode !== "host"
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
>
<div className="space-y-4 mt-4 p-1">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 items-start mb-4">
<div className="min-w-0 col-span-1">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("site")}
</FormLabel>
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -753,9 +605,7 @@ export function InternalResourceForm({
s.siteId ===
field.value
)?.name
: t(
"selectSite"
)}
: t("selectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
@@ -763,21 +613,11 @@ export function InternalResourceForm({
<PopoverContent className="w-full p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedSite
}
filterTypes={[
"newt"
]}
onSelectSite={(
site
) => {
setSelectedSite(
site
);
field.onChange(
site.siteId
);
selectedSite={selectedSite}
filterTypes={["newt"]}
onSelectSite={(site) => {
setSelectedSite(site);
field.onChange(site.siteId);
}}
/>
</PopoverContent>
@@ -787,84 +627,79 @@ export function InternalResourceForm({
)}
/>
</div>
<div className="min-w-0 col-span-2">
<FormField
control={form.control}
name="mode"
render={({ field }) => {
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
[
<HorizontalTabs
clientSide
items={[
{
value: "host",
label: t(modeHostKey)
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
value: "cidr",
label: t(modeCidrKey)
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
{
value: "http",
label: t(modeHttpKey)
}
];
return (
<FormItem>
<FormLabel>
{t(modeLabelKey)}
</FormLabel>
<OptionSelect<InternalResourceMode>
options={modeOptions}
value={field.value}
onChange={
field.onChange
}
cols={3}
/>
<FormMessage />
</FormItem>
);
}}
/>
...(disableEnterpriseFeatures || mode === "cidr"
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
>
<div className="space-y-4 mt-4 p-1">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr" && "grid-cols-1",
mode === "http" && "grid-cols-3",
mode === "host" && "grid-cols-2"
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
{mode === "http" && (
<div className="min-w-0">
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField
control={form.control}
name="scheme"
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(schemeLabelKey)}
{t(modeLabelKey)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={
field.value ??
"http"
}
value={field.value}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">
http
<SelectItem value="host">
{t(modeHostKey)}
</SelectItem>
<SelectItem value="https">
https
<SelectItem value="cidr">
{t(modeCidrKey)}
</SelectItem>
</SelectContent>
</Select>
@@ -873,13 +708,12 @@ export function InternalResourceForm({
)}
/>
</div>
)}
<div
className={cn(
mode === "cidr" && "col-span-1",
(mode === "http" || mode === "host") &&
"min-w-0"
)}
className={
mode === "cidr"
? "col-span-3"
: "col-span-5"
}
>
<FormField
control={form.control}
@@ -890,18 +724,15 @@ export function InternalResourceForm({
{t(destinationLabelKey)}
</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{mode === "host" && (
<div className="min-w-0">
{mode !== "cidr" && (
<div className="col-span-4">
<FormField
control={form.control}
name="alias"
@@ -913,7 +744,6 @@ export function InternalResourceForm({
<FormControl>
<Input
{...field}
className="w-full"
value={
field.value ??
""
@@ -926,144 +756,9 @@ export function InternalResourceForm({
/>
</div>
)}
{mode === "http" && (
<div className="min-w-0">
<FormField
control={form.control}
name="httpHttpsPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
httpHttpsPortLabelKey
)}
</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={
field.value ??
""
}
onChange={(e) => {
const raw =
e.target
.value;
if (
raw === ""
) {
field.onChange(
null
);
return;
}
const n =
Number(raw);
field.onChange(
Number.isFinite(
n
)
? n
: null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
{isHttpMode ? (
<div className="space-y-4">
<div className="my-8">
<label className="font-medium block">
{t(httpConfigurationTitleKey)}
</label>
<div className="text-sm text-muted-foreground">
{t(httpConfigurationDescriptionKey)}
</div>
</div>
<DomainPicker
key={
variant === "edit" && siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
form.setValue("alias", null);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
form.setValue("alias", res.fullDomain);
}}
/>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
</div>
) : (
<div className="space-y-4">
<div className="my-8">
<label className="font-medium block">
@@ -1111,11 +806,7 @@ export function InternalResourceForm({
value={tcpPortMode}
onValueChange={(
v: PortMode
) =>
setTcpPortMode(
v
)
}
) => setTcpPortMode(v)}
>
<FormControl>
<SelectTrigger className="w-[110px]">
@@ -1124,19 +815,13 @@ export function InternalResourceForm({
</FormControl>
<SelectContent>
<SelectItem value="all">
{t(
"allPorts"
)}
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t(
"blocked"
)}
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t(
"custom"
)}
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
@@ -1148,12 +833,9 @@ export function InternalResourceForm({
value={
tcpCustomPorts
}
onChange={(
e
) =>
onChange={(e) =>
setTcpCustomPorts(
e
.target
e.target
.value
)
}
@@ -1217,11 +899,7 @@ export function InternalResourceForm({
value={udpPortMode}
onValueChange={(
v: PortMode
) =>
setUdpPortMode(
v
)
}
) => setUdpPortMode(v)}
>
<FormControl>
<SelectTrigger className="w-[110px]">
@@ -1230,19 +908,13 @@ export function InternalResourceForm({
</FormControl>
<SelectContent>
<SelectItem value="all">
{t(
"allPorts"
)}
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t(
"blocked"
)}
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t(
"custom"
)}
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
@@ -1254,12 +926,9 @@ export function InternalResourceForm({
value={
udpCustomPorts
}
onChange={(
e
) =>
onChange={(e) =>
setUdpCustomPorts(
e
.target
e.target
.value
)
}
@@ -1303,9 +972,7 @@ export function InternalResourceForm({
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogIcmp"
)}
{t("editInternalResourceDialogIcmp")}
</FormLabel>
</div>
<div
@@ -1348,7 +1015,6 @@ export function InternalResourceForm({
</div>
</div>
</div>
)}
</div>
<div className="space-y-4 mt-4 p-1">
@@ -1547,8 +1213,8 @@ export function InternalResourceForm({
)}
</div>
{/* SSH Access tab (host mode only) */}
{!disableEnterpriseFeatures && mode === "host" && (
{/* SSH Access tab */}
{!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">