Compare commits

..

5 Commits

Author SHA1 Message Date
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
54 changed files with 863 additions and 3002 deletions

1
.github/CODEOWNERS vendored
View File

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

View File

@@ -1817,11 +1817,6 @@
"editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModePort": "Port",
"editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeHost": "Host",
"editInternalResourceDialogModeCidr": "CIDR", "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", "editInternalResourceDialogDestination": "Destination",
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "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.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
@@ -1837,7 +1832,6 @@
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Select site...", "selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"noSitesFound": "No sites found.", "noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -1866,19 +1860,11 @@
"createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModePort": "Port",
"createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeHost": "Host",
"createInternalResourceDialogModeCidr": "CIDR", "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", "createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "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.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "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", "siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
@@ -2130,7 +2116,6 @@
"domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerFreeProvidedDomain": "Free Provided Domain",
"domainPickerVerified": "Verified", "domainPickerVerified": "Verified",
"domainPickerUnverified": "Unverified", "domainPickerUnverified": "Unverified",
"domainPickerManual": "Manual",
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
"domainPickerError": "Error", "domainPickerError": "Error",
"domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorLoadDomains": "Failed to load organization domains",
@@ -2437,7 +2422,6 @@
"validPassword": "Valid Password", "validPassword": "Valid Password",
"validEmail": "Valid email", "validEmail": "Valid email",
"validSSO": "Valid SSO", "validSSO": "Valid SSO",
"connectedClient": "Connected Client",
"resourceBlocked": "Resource Blocked", "resourceBlocked": "Resource Blocked",
"droppedByRule": "Dropped by Rule", "droppedByRule": "Dropped by Rule",
"noSessions": "No Sessions", "noSessions": "No Sessions",
@@ -2675,12 +2659,8 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", "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.", "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", "editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogIcmp": "ICMP",
@@ -2719,8 +2699,6 @@
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
"maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageMessageDescription": "Detailed message explaining the maintenance",
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)", "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", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
"editDomain": "Edit Domain", "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 settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
settingsLogRetentionDaysConnection: integer( settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -103,9 +101,7 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
status: varchar("status") status: varchar("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">()
.default("approved")
}); });
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
@@ -234,9 +230,8 @@ export const siteResources = pgTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false), mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" protocol: varchar("protocol"), // only for port mode
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
@@ -249,12 +244,7 @@ export const siteResources = pgTable("siteResources", {
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">() .$type<"site" | "remote">()
.default("site"), .default("site")
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain")
}); });
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable("clientSiteResources", {
@@ -1004,7 +994,6 @@ export const requestAuditLog = pgTable(
actor: text("actor"), actor: text("actor"),
actorId: text("actorId"), actorId: text("actorId"),
resourceId: integer("resourceId"), resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"), ip: text("ip"),
location: text("location"), location: text("location"),
userAgent: text("userAgent"), userAgent: text("userAgent"),

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 settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
settingsLogRetentionDaysConnection: integer( settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
"settingsLogRetentionDaysConnection"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull() .notNull()
.default(0), .default(0),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
@@ -260,9 +258,8 @@ export const siteResources = sqliteTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" protocol: text("protocol"), // only for port mode
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname destination: text("destination").notNull(), // ip, cidr, hostname
@@ -277,12 +274,7 @@ export const siteResources = sqliteTable("siteResources", {
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">() .$type<"site" | "remote">()
.default("site"), .default("site")
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
}); });
export const clientSiteResources = sqliteTable("clientSiteResources", { export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -1104,7 +1096,6 @@ export const requestAuditLog = sqliteTable(
actor: text("actor"), actor: text("actor"),
actorId: text("actorId"), actorId: text("actorId"),
resourceId: integer("resourceId"), resourceId: integer("resourceId"),
siteResourceId: integer("siteResourceId"),
ip: text("ip"), ip: text("ip"),
location: text("location"), location: text("location"),
userAgent: text("userAgent"), userAgent: text("userAgent"),

View File

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

View File

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

View File

@@ -19,8 +19,7 @@ export enum TierFeature {
SshPam = "sshPam", SshPam = "sshPam",
FullRbac = "fullRbac", FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations SIEM = "siem" // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources" // handle downgrade by disabling HTTP private resources
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -57,6 +56,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"], [TierFeature.SIEM]: ["enterprise"]
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"]
}; };

View File

@@ -1,8 +1,6 @@
import { import {
clients, clients,
clientSiteResources, clientSiteResources,
domains,
orgDomains,
roles, roles,
roleSiteResources, roleSiteResources,
SiteResource, SiteResource,
@@ -13,97 +11,10 @@ import {
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm"; import { eq, and, ne, inArray, or } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip"; import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
fullDomain: string,
orgId: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string }> {
const [fullDomainExists] = await trx
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, fullDomain),
eq(siteResources.orgId, orgId),
siteResourceId
? ne(siteResources.siteResourceId, siteResourceId)
: isNotNull(siteResources.siteResourceId)
)
)
.limit(1);
if (fullDomainExists) {
throw new Error(
`Site resource already exists with domain: ${fullDomain} in org ${orgId}`
);
}
const possibleDomains = await trx
.select()
.from(domains)
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
.execute();
if (possibleDomains.length === 0) {
throw new Error(
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
);
}
const validDomains = possibleDomains.filter((domain) => {
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return (
fullDomain === domain.domains.baseDomain ||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
);
} else if (domain.domains.type == "cname") {
return fullDomain === domain.domains.baseDomain;
}
});
if (validDomains.length === 0) {
throw new Error(
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
);
}
const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain;
let subdomain: string | null = null;
if (fullDomain !== baseDomain) {
subdomain = fullDomain.replace(`.${baseDomain}`, "");
}
await createCertificate(domainSelection.domainId, fullDomain, trx);
return {
subdomain,
domainId: domainSelection.domainId
};
}
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 = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
@@ -165,40 +76,20 @@ export async function updateClientResources(
} }
if (existingResource) { if (existingResource) {
const mappedMode = siteResourceModeForDb(resourceData.mode);
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && mappedMode.mode === "http") {
domainInfo = await getDomainForSiteResource(
existingResource.siteResourceId,
resourceData["full-domain"],
orgId,
trx
);
}
// Update existing resource // Update existing resource
const [updatedResource] = await trx const [updatedResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId, siteId: site.siteId,
mode: mappedMode.mode, mode: resourceData.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: resourceData["disable-icmp"], disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"], tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"], udpPortRangeString: resourceData["udp-ports"]
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
}) })
.where( .where(
eq( eq(
@@ -209,6 +100,7 @@ export async function updateClientResources(
.returning(); .returning();
const siteResourceId = existingResource.siteResourceId; const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId;
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
@@ -315,24 +207,12 @@ export async function updateClientResources(
oldSiteResource: existingResource oldSiteResource: existingResource
}); });
} else { } else {
const mappedMode = siteResourceModeForDb(resourceData.mode);
let aliasAddress: string | null = null; 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); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && mappedMode.mode === "http") {
domainInfo = await getDomainForSiteResource(
undefined,
resourceData["full-domain"],
orgId,
trx
);
}
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(siteResources) .insert(siteResources)
@@ -341,21 +221,15 @@ export async function updateClientResources(
siteId: site.siteId, siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: mappedMode.mode, mode: resourceData.mode,
ssl: mappedMode.ssl,
scheme: mappedMode.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: resourceData["disable-icmp"], disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"], tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"], udpPortRangeString: resourceData["udp-ports"]
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
}) })
.returning(); .returning();

View File

@@ -1100,7 +1100,7 @@ function checkIfTargetChanged(
return false; return false;
} }
export async function getDomain( async function getDomain(
resourceId: number | undefined, resourceId: number | undefined,
fullDomain: string, fullDomain: string,
orgId: string, orgId: string,

View File

@@ -325,18 +325,16 @@ export function isTargetsOnlyResource(resource: any): boolean {
export const ClientResourceSchema = z export const ClientResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr"]),
site: z.string(), site: z.string(),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), // destinationPort: z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1),
// enabled: z.boolean().default(true), // enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false), "disable-icmp": z.boolean().optional().default(false),
"full-domain": z.string().optional(),
ssl: z.boolean().optional(),
alias: z alias: z
.string() .string()
.regex( .regex(
@@ -479,39 +477,6 @@ export const ConfigSchema = z
}); });
} }
// Enforce the full-domain uniqueness across client-resources in the same stack
const clientFullDomainMap = new Map<string, string[]>();
Object.entries(config["client-resources"]).forEach(
([resourceKey, resource]) => {
const fullDomain = resource["full-domain"];
if (fullDomain) {
if (!clientFullDomainMap.has(fullDomain)) {
clientFullDomainMap.set(fullDomain, []);
}
clientFullDomainMap.get(fullDomain)!.push(resourceKey);
}
}
);
const clientFullDomainDuplicates = Array.from(
clientFullDomainMap.entries()
)
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
.map(
([fullDomain, resourceKeys]) =>
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
)
.join("; ");
if (clientFullDomainDuplicates.length !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["client-resources"],
message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}`
});
}
// Enforce proxy-port uniqueness within proxy-resources per protocol // Enforce proxy-port uniqueness within proxy-resources per protocol
const protocolPortMap = new Map<string, string[]>(); const protocolPortMap = new Map<string, string[]>();

39
server/lib/encryption.ts Normal file
View File

@@ -0,0 +1,39 @@
import crypto from "crypto";
export function encryptData(data: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(data, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// Combine IV, auth tag, and encrypted data
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
}
// Helper function to decrypt data (you'll need this to read certificates)
export function decryptData(encryptedData: string, key: Buffer): string {
const algorithm = "aes-256-gcm";
const parts = encryptedData.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted data format");
}
const iv = Buffer.from(parts[0], "hex");
const authTag = Buffer.from(parts[1], "hex");
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
// openssl rand -hex 32 > config/encryption.key

View File

@@ -5,7 +5,6 @@ import config from "@server/lib/config";
import z from "zod"; import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -478,9 +477,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http"))) .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
.map((sr) => ({ .map((sr) => ({
alias: sr.alias || sr.fullDomain, alias: sr.alias,
aliasAddress: sr.aliasAddress aliasAddress: sr.aliasAddress
})); }));
} }
@@ -583,26 +582,16 @@ export type SubnetProxyTargetV2 = {
protocol: "tcp" | "udp"; protocol: "tcp" | "udp";
}[]; }[];
resourceId?: number; resourceId?: number;
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
httpTargets?: HTTPTarget[];
tlsCert?: string;
tlsKey?: string;
}; };
export type HTTPTarget = { export function generateSubnetProxyTargetV2(
destAddr: string; // must be an IP or hostname
destPort: number;
scheme: "http" | "https";
};
export async function generateSubnetProxyTargetV2(
siteResource: SiteResource, siteResource: SiteResource,
clients: { clients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[] }[]
): Promise<SubnetProxyTargetV2 | undefined> { ): SubnetProxyTargetV2 | undefined {
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
@@ -630,7 +619,7 @@ export async function generateSubnetProxyTargetV2(
destPrefix: destination, destPrefix: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId,
}; };
} }
@@ -642,7 +631,7 @@ export async function generateSubnetProxyTargetV2(
rewriteTo: destination, rewriteTo: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId,
}; };
} }
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
@@ -651,68 +640,7 @@ export async function generateSubnetProxyTargetV2(
destPrefix: siteResource.destination, destPrefix: siteResource.destination,
portRange, portRange,
disableIcmp, 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.aliasAddress ||
!siteResource.destinationPort ||
!siteResource.scheme ||
!siteResource.fullDomain
) {
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;
}
// also push a match for the alias address
let tlsCert: string | undefined;
let tlsKey: string | undefined;
if (siteResource.ssl && siteResource.fullDomain) {
try {
const certs = await getValidCertificatesForDomains(
new Set([siteResource.fullDomain]),
true
);
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
} else {
logger.warn(
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
);
}
} catch (err) {
logger.error(
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
);
}
}
target = {
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId,
protocol: siteResource.ssl ? "https" : "http",
httpTargets: [
{
destAddr: siteResource.destination,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
],
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
}; };
} }
@@ -742,15 +670,16 @@ export async function generateSubnetProxyTargetV2(
return target; return target;
} }
/** /**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry. * by expanding each source prefix into its own target entry.
* @param targetV2 - The v2 target to convert * @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects * @returns Array of v1 SubnetProxyTarget objects
*/ */
export function convertSubnetProxyTargetsV2ToV1( export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[] targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] { ): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) => return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({ targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix, sourcePrefix,
@@ -766,7 +695,8 @@ export function convertSubnetProxyTargetsV2ToV1(
}) })
})) }))
); );
} }
// Custom schema for validating port range strings // Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string // Format: "80,443,8000-9000" or "*" for all ports, or empty string

View File

@@ -661,7 +661,7 @@ async function handleSubnetProxyTargetUpdates(
); );
if (addedClients.length > 0) { if (addedClients.length > 0) {
const targetToAdd = await generateSubnetProxyTargetV2( const targetToAdd = generateSubnetProxyTargetV2(
siteResource, siteResource,
addedClients addedClients
); );
@@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates(
); );
if (removedClients.length > 0) { if (removedClients.length > 0) {
const targetToRemove = await generateSubnetProxyTargetV2( const targetToRemove = generateSubnetProxyTargetV2(
siteResource, siteResource,
removedClients removedClients
); );
@@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = await generateSubnetProxyTargetV2(resource, [ const target = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1241,7 +1241,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = await generateSubnetProxyTargetV2(resource, [ const target = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,

View File

@@ -1,443 +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,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
SiteResource,
siteResources
} from "@server/db";
import { and, eq } from "drizzle-orm";
import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import privateConfig from "#private/lib/config";
import config from "@server/lib/config";
import { generateSubnetProxyTargetV2, SubnetProxyTargetV2 } from "@server/lib/ip";
import { updateTargets } from "@server/routers/client/targets";
import cache from "#private/lib/cache";
import { build } from "@server/build";
interface AcmeCert {
domain: { main: string; sans?: string[] };
certificate: string;
key: string;
Store: string;
}
interface AcmeJson {
[resolver: string]: {
Certificates: AcmeCert[];
};
}
async function pushCertUpdateToAffectedNewts(
domain: string,
domainId: string | null,
oldCertPem: string | null,
oldKeyPem: string | null
): Promise<void> {
// Find all SSL-enabled HTTP site resources that use this cert's domain
let affectedResources: SiteResource[] = [];
if (domainId) {
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.domainId, domainId),
eq(siteResources.ssl, true)
)
);
} else {
// Fallback: match by exact fullDomain when no domainId is available
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, domain),
eq(siteResources.ssl, true)
)
);
}
if (affectedResources.length === 0) {
logger.debug(
`acmeCertSync: no affected site resources for cert domain "${domain}"`
);
return;
}
logger.info(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
);
for (const resource of affectedResources) {
try {
// Get the newt for this site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}`
);
continue;
}
// Get all clients with access to this resource
const resourceClients = await db
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
if (resourceClients.length === 0) {
logger.debug(
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
if (resource.fullDomain) {
await cache.del(`cert:${resource.fullDomain}`);
}
// Generate the new target (will read the freshly updated cert from DB)
const newTarget = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (!newTarget) {
logger.debug(
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Construct the old target — same routing shape but with the previous cert/key.
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
// semantics correct so the update message accurately reflects what changed.
const oldTarget: SubnetProxyTargetV2 = {
...newTarget,
tlsCert: oldCertPem ?? undefined,
tlsKey: oldKeyPem ?? undefined
};
await updateTargets(
newt.newtId,
{ oldTargets: [oldTarget], newTargets: [newTarget] },
newt.version
);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}`
);
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
);
}
}
}
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;
}
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);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
if (storedCertPem === certPem) {
logger.debug(
`acmeCertSync: cert for ${domain} is unchanged, skipping`
);
continue;
}
// Cert has changed; capture old values so we can send a correct
// update message to the newt after the DB write.
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} 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 = encrypt(certPem, config.getRawConfig().server.secret!);
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
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"})`
);
await pushCertUpdateToAffectedNewts(domain, domainId, oldCertPem, oldKeyPem);
} 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"})`
);
// For a brand-new cert, push to any SSL resources that were waiting for it
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
export function initAcmeCertSync(): void {
if (build == "saas") {
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
return;
}
const privateConfigData = privateConfig.getRawPrivateConfig();
if (!privateConfigData.flags?.enable_acme_cert_sync) {
logger.debug(`acmeCertSync: ACME cert sync is disabled by config flag, skipping`);
return;
}
if (!privateConfigData.flags.use_pangolin_dns) {
logger.debug(`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be enabled, skipping`);
return;
}
const acmeJsonPath =
privateConfigData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
const intervalMs = privateConfigData.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

@@ -11,15 +11,23 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import privateConfig from "./config"; import config from "./config";
import config from "@server/lib/config";
import { certificates, db } from "@server/db"; import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto"; import { decryptData } from "@server/lib/encryption";
import logger from "@server/logger"; import logger from "@server/logger";
import cache from "#private/lib/cache"; import cache from "#private/lib/cache";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Define the return type for clarity and type safety // Define the return type for clarity and type safety
export type CertificateResult = { export type CertificateResult = {
@@ -37,7 +45,7 @@ export async function getValidCertificatesForDomains(
domains: Set<string>, domains: Set<string>,
useCache: boolean = true useCache: boolean = true
): Promise<Array<CertificateResult>> { ): Promise<Array<CertificateResult>> {
loadEncryptData(); // Ensure encryption key is loaded
const finalResults: CertificateResult[] = []; const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>(); const domainsToQuery = new Set<string>();
@@ -60,7 +68,7 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early // 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) { if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults; return decryptedResults;
} }
@@ -165,23 +173,22 @@ export async function getValidCertificatesForDomains(
} }
} }
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!); const decryptedResults = decryptFinalResults(finalResults);
return decryptedResults; return decryptedResults;
} }
function decryptFinalResults( function decryptFinalResults(
finalResults: CertificateResult[], finalResults: CertificateResult[]
secret: string
): CertificateResult[] { ): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => { const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decrypt( const decryptedCert = decryptData(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
secret encryptionKey
); );
// Decrypt and save key file // Decrypt and save key file
const decryptedKey = decrypt(cert.keyFile!, secret); const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
// Return only the certificate data without org information // Return only the certificate data without org information
return { return {

View File

@@ -34,6 +34,10 @@ export const privateConfigSchema = z.object({
}), }),
server: z server: z
.object({ .object({
encryption_key: z
.string()
.optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
reo_client_id: z reo_client_id: z
.string() .string()
.optional() .optional()
@@ -91,21 +95,10 @@ export const privateConfigSchema = z.object({
.object({ .object({
enable_redis: z.boolean().optional().default(false), enable_redis: z.boolean().optional().default(false),
use_pangolin_dns: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false),
use_org_only_idp: z.boolean().optional(), use_org_only_idp: z.boolean().optional()
enable_acme_cert_sync: z.boolean().optional().default(true)
}) })
.optional() .optional()
.prefault({}), .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 branding: z
.object({ .object({
app_name: z.string().optional(), app_name: z.string().optional(),

View File

@@ -33,7 +33,7 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { orgs, resources, sites, siteResources, Target, targets } from "@server/db"; import { orgs, resources, sites, Target, targets } from "@server/db";
import { import {
sanitize, sanitize,
encodePath, encodePath,
@@ -267,34 +267,6 @@ export async function getTraefikConfig(
}); });
}); });
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(sites, eq(sites.siteId, siteResources.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
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[] = []; let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// create a list of all domains to get certs for // create a list of all domains to get certs for
@@ -304,12 +276,6 @@ export async function getTraefikConfig(
domains.add(resource.fullDomain); domains.add(resource.fullDomain);
} }
} }
// Include siteResource aliases so pangolin-dns also fetches certs for them
for (const sr of siteResourcesWithFullDomain) {
if (sr.fullDomain) {
domains.add(sr.fullDomain);
}
}
// get the valid certs for these domains // get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -901,139 +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 (siteResourcesWithFullDomain.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 siteResourcesWithFullDomain) {
if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.fullDomain)) continue;
const fullDomain = sr.fullDomain;
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(\`${fullDomain}\`)`,
priority: 100
};
// Determine TLS / cert-resolver configuration
let tls: any = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
const domainParts = fullDomain.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 === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for siteResource alias: ${fullDomain}`
);
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(\`${fullDomain}\`)`,
priority: 100,
tls
};
// Assets bypass router — lets Next.js static files load without rewrite
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 101,
tls
};
}
}
if (generateLoginPageRouters) { if (generateLoginPageRouters) {
const exitNodeLoginPages = await db const exitNodeLoginPages = await db
.select({ .select({

View File

@@ -24,8 +24,14 @@ import {
User, User,
certificates, certificates,
exitNodeOrgs, exitNodeOrgs,
RemoteExitNode,
olms,
newts,
clients,
sites,
domains, domains,
orgDomains, orgDomains,
targets,
loginPage, loginPage,
loginPageOrg, loginPageOrg,
LoginPage, LoginPage,
@@ -64,9 +70,12 @@ import {
updateAndGenerateEndpointDestinations, updateAndGenerateEndpointDestinations,
updateSiteBandwidth updateSiteBandwidth
} from "@server/routers/gerbil"; } from "@server/routers/gerbil";
import * as gerbil from "@server/routers/gerbil";
import logger from "@server/logger"; import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto"; import { decryptData } from "@server/lib/encryption";
import config from "@server/lib/config"; import config from "@server/lib/config";
import privateConfig from "#private/lib/config";
import * as fs from "fs";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
@@ -289,11 +298,25 @@ hybridRouter.get(
} }
); );
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
if (encryptionKey) {
return; // already loaded
}
encryptionKeyHex =
privateConfig.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}
// Get valid certificates for given domains (supports wildcard certs) // Get valid certificates for given domains (supports wildcard certs)
hybridRouter.get( hybridRouter.get(
"/certificates/domains", "/certificates/domains",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
loadEncryptData(); // Ensure encryption key is loaded
const parsed = getCertificatesByDomainsQuerySchema.safeParse( const parsed = getCertificatesByDomainsQuerySchema.safeParse(
req.query req.query
); );
@@ -424,13 +447,13 @@ hybridRouter.get(
const result = filtered.map((cert) => { const result = filtered.map((cert) => {
// Decrypt and save certificate file // Decrypt and save certificate file
const decryptedCert = decrypt( const decryptedCert = decryptData(
cert.certFile!, // is not null from query cert.certFile!, // is not null from query
config.getRawConfig().server.secret! encryptionKey
); );
// Decrypt and save key file // Decrypt and save key file
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!); const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
// Return only the certificate data without org information // Return only the certificate data without org information
return { return {
@@ -810,12 +833,9 @@ hybridRouter.get(
) )
); );
logger.debug( logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
`User ${userId} has roles in org ${orgId}:`,
userOrgRoleRows
);
return response<{ roleId: number; roleName: string }[]>(res, { return response<{ roleId: number, roleName: string }[]>(res, {
data: userOrgRoleRows, data: userOrgRoleRows,
success: true, success: true,
error: false, error: false,

View File

@@ -92,14 +92,9 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
return; return;
} }
// Look up the org for this site and check retention settings // Look up the org for this site
const [site] = await db const [site] = await db
.select({ .select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
})
.from(sites) .from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId)) .innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId)); .where(eq(sites.siteId, newt.siteId));
@@ -113,13 +108,6 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
const orgId = site.orgId; const orgId = site.orgId;
if (site.settingsLogRetentionDaysConnection === 0) {
logger.debug(
`Connection log retention is disabled for org ${orgId}, skipping`
);
return;
}
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
// reconstruct the exact subnet string stored on each client record. // reconstruct the exact subnet string stored on each client record.
const cidrSuffix = site.orgSubnet?.includes("/") const cidrSuffix = site.orgSubnet?.includes("/")

View File

@@ -1,238 +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 { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { inflate } from "zlib";
import { promisify } from "util";
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
import { getCountryCodeForIp } from "@server/lib/geoip";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
const zlibInflate = promisify(inflate);
interface HTTPRequestLogData {
requestId: string;
resourceId: number; // siteResourceId
timestamp: string; // ISO 8601
method: string;
scheme: string; // "http" or "https"
host: string;
path: string;
rawQuery?: string;
userAgent?: string;
sourceAddr: string; // ip:port
tls: boolean;
}
/**
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
*/
async function decompressRequestLog(
compressed: string
): Promise<HTTPRequestLogData[]> {
const compressedBuffer = Buffer.from(compressed, "base64");
const decompressed = await zlibInflate(compressedBuffer);
const jsonString = decompressed.toString("utf-8");
const parsed = JSON.parse(jsonString);
if (!Array.isArray(parsed)) {
throw new Error("Decompressed request log data is not an array");
}
return parsed;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
const { message, client } = context;
const newt = client as Newt;
if (!newt) {
logger.warn("Request log received but no newt client in context");
return;
}
if (!newt.siteId) {
logger.warn("Request log received but newt has no siteId");
return;
}
if (!message.data?.compressed) {
logger.warn("Request log message missing compressed data");
return;
}
// Look up the org for this site and check retention settings
const [site] = await db
.select({
orgId: sites.orgId,
orgSubnet: orgs.subnet,
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest
})
.from(sites)
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(eq(sites.siteId, newt.siteId));
if (!site) {
logger.warn(
`Request log received but site ${newt.siteId} not found in database`
);
return;
}
const orgId = site.orgId;
if (site.settingsLogRetentionDaysRequest === 0) {
logger.debug(
`Request log retention is disabled for org ${orgId}, skipping`
);
return;
}
let entries: HTTPRequestLogData[];
try {
entries = await decompressRequestLog(message.data.compressed);
} catch (error) {
logger.error("Failed to decompress request log data:", error);
return;
}
if (entries.length === 0) {
return;
}
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
// Build a map from sourceIp → external endpoint string by joining clients
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
// of the client device and is used for GeoIP lookup.
const ipToEndpoint = new Map<string, string>();
const cidrSuffix = site.orgSubnet?.includes("/")
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
: null;
if (cidrSuffix) {
const uniqueSourceAddrs = new Set<string>();
for (const entry of entries) {
if (entry.sourceAddr) {
uniqueSourceAddrs.add(entry.sourceAddr);
}
}
if (uniqueSourceAddrs.size > 0) {
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
return `${ip}${cidrSuffix}`;
});
const matchedClients = await db
.select({
subnet: clients.subnet,
endpoint: clientSitesAssociationsCache.endpoint
})
.from(clients)
.innerJoin(
clientSitesAssociationsCache,
and(
eq(
clientSitesAssociationsCache.clientId,
clients.clientId
),
eq(clientSitesAssociationsCache.siteId, newt.siteId)
)
)
.where(
and(
eq(clients.orgId, orgId),
inArray(clients.subnet, subnetQueries)
)
);
for (const c of matchedClients) {
if (c.endpoint) {
const ip = c.subnet.split("/")[0];
ipToEndpoint.set(ip, c.endpoint);
}
}
}
}
for (const entry of entries) {
if (
!entry.requestId ||
!entry.resourceId ||
!entry.method ||
!entry.scheme ||
!entry.host ||
!entry.path ||
!entry.sourceAddr
) {
logger.debug(
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
);
continue;
}
const originalRequestURL =
entry.scheme +
"://" +
entry.host +
entry.path +
(entry.rawQuery ? "?" + entry.rawQuery : "");
// Resolve the client's external endpoint for GeoIP lookup.
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
const sourceIp = entry.sourceAddr.includes(":")
? entry.sourceAddr.split(":")[0]
: entry.sourceAddr;
const endpoint = ipToEndpoint.get(sourceIp);
let location: string | undefined;
if (endpoint) {
const endpointIp = endpoint.includes(":")
? endpoint.split(":")[0]
: endpoint;
location = await getCountryCodeForIp(endpointIp);
}
await logRequestAudit(
{
action: true,
reason: 108,
siteResourceId: entry.resourceId,
orgId,
location
},
{
path: entry.path,
originalRequestURL,
scheme: entry.scheme,
host: entry.host,
method: entry.method,
tls: entry.tls,
requestIp: entry.sourceAddr
}
);
}
logger.debug(
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
);
};

View File

@@ -12,4 +12,3 @@
*/ */
export * from "./handleConnectionLogMessage"; export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";

View File

@@ -18,13 +18,12 @@ import {
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build"; import { build } from "@server/build";
import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt"; import { handleConnectionLogMessage } from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage, "newt/access-log": handleConnectionLogMessage,
"newt/request-log": handleRequestLogMessage,
}; };
if (build != "saas") { if (build != "saas") {

View File

@@ -1,8 +1,8 @@
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm"; import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -92,10 +92,7 @@ function getWhere(data: Q) {
lt(requestAuditLog.timestamp, data.timeEnd), lt(requestAuditLog.timestamp, data.timeEnd),
eq(requestAuditLog.orgId, data.orgId), eq(requestAuditLog.orgId, data.orgId),
data.resourceId data.resourceId
? or( ? eq(requestAuditLog.resourceId, data.resourceId)
eq(requestAuditLog.resourceId, data.resourceId),
eq(requestAuditLog.siteResourceId, data.resourceId)
)
: undefined, : undefined,
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
data.method ? eq(requestAuditLog.method, data.method) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined,
@@ -121,7 +118,6 @@ export function queryRequest(data: Q) {
actor: requestAuditLog.actor, actor: requestAuditLog.actor,
actorId: requestAuditLog.actorId, actorId: requestAuditLog.actorId,
resourceId: requestAuditLog.resourceId, resourceId: requestAuditLog.resourceId,
siteResourceId: requestAuditLog.siteResourceId,
ip: requestAuditLog.ip, ip: requestAuditLog.ip,
location: requestAuditLog.location, location: requestAuditLog.location,
userAgent: requestAuditLog.userAgent, userAgent: requestAuditLog.userAgent,
@@ -141,22 +137,17 @@ export function queryRequest(data: Q) {
} }
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) { async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
// If logs database is the same as main database, we can do a join
// Otherwise, we need to fetch resource details separately
const resourceIds = logs const resourceIds = logs
.map(log => log.resourceId) .map(log => log.resourceId)
.filter((id): id is number => id !== null && id !== undefined); .filter((id): id is number => id !== null && id !== undefined);
const siteResourceIds = logs if (resourceIds.length === 0) {
.filter(log => log.resourceId == null && log.siteResourceId != null)
.map(log => log.siteResourceId)
.filter((id): id is number => id !== null && id !== undefined);
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
} }
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>(); // Fetch resource details from main database
if (resourceIds.length > 0) {
const resourceDetails = await primaryDb const resourceDetails = await primaryDb
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
@@ -166,48 +157,17 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
.from(resources) .from(resources)
.where(inArray(resources.resourceId, resourceIds)); .where(inArray(resources.resourceId, resourceIds));
for (const r of resourceDetails) { // Create a map for quick lookup
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); const resourceMap = new Map(
} resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
} );
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name,
niceId: siteResources.niceId
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
for (const r of siteResourceDetails) {
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
}
}
// Enrich logs with resource details // Enrich logs with resource details
return logs.map(log => { return logs.map(log => ({
if (log.resourceId != null) {
const details = resourceMap.get(log.resourceId);
return {
...log, ...log,
resourceName: details?.name ?? null, resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
resourceNiceId: details?.niceId ?? null resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
}; }));
} else if (log.siteResourceId != null) {
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};
}
return { ...log, resourceName: null, resourceNiceId: null };
});
} }
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
@@ -251,8 +211,7 @@ async function queryUniqueFilterAttributes(
uniqueLocations, uniqueLocations,
uniqueHosts, uniqueHosts,
uniquePaths, uniquePaths,
uniqueResources, uniqueResources
uniqueSiteResources
] = await Promise.all([ ] = await Promise.all([
primaryLogsDb primaryLogsDb
.selectDistinct({ actor: requestAuditLog.actor }) .selectDistinct({ actor: requestAuditLog.actor })
@@ -280,13 +239,6 @@ async function queryUniqueFilterAttributes(
}) })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions) .where(baseConditions)
.limit(DISTINCT_LIMIT + 1),
primaryLogsDb
.selectDistinct({
id: requestAuditLog.siteResourceId
})
.from(requestAuditLog)
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
.limit(DISTINCT_LIMIT + 1) .limit(DISTINCT_LIMIT + 1)
]); ]);
@@ -307,10 +259,6 @@ async function queryUniqueFilterAttributes(
.map(row => row.id) .map(row => row.id)
.filter((id): id is number => id !== null); .filter((id): id is number => id !== null);
const siteResourceIds = uniqueSiteResources
.map(row => row.id)
.filter((id): id is number => id !== null);
let resourcesWithNames: Array<{ id: number; name: string | null }> = []; let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
if (resourceIds.length > 0) { if (resourceIds.length > 0) {
@@ -322,31 +270,10 @@ async function queryUniqueFilterAttributes(
.from(resources) .from(resources)
.where(inArray(resources.resourceId, resourceIds)); .where(inArray(resources.resourceId, resourceIds));
resourcesWithNames = [ resourcesWithNames = resourceDetails.map(r => ({
...resourcesWithNames,
...resourceDetails.map(r => ({
id: r.resourceId, id: r.resourceId,
name: r.name name: r.name
})) }));
];
}
if (siteResourceIds.length > 0) {
const siteResourceDetails = await primaryDb
.select({
siteResourceId: siteResources.siteResourceId,
name: siteResources.name
})
.from(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds));
resourcesWithNames = [
...resourcesWithNames,
...siteResourceDetails.map(r => ({
id: r.siteResourceId,
name: r.name
}))
];
} }
return { return {

View File

@@ -28,7 +28,6 @@ export type QueryRequestAuditLogResponse = {
actor: string | null; actor: string | null;
actorId: string | null; actorId: string | null;
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null;
resourceNiceId: string | null; resourceNiceId: string | null;
resourceName: string | null; resourceName: string | null;
ip: string | null; ip: string | null;

View File

@@ -18,7 +18,6 @@ Reasons:
105 - Valid Password 105 - Valid Password
106 - Valid email 106 - Valid email
107 - Valid SSO 107 - Valid SSO
108 - Connected Client
201 - Resource Not Found 201 - Resource Not Found
202 - Resource Blocked 202 - Resource Blocked
@@ -39,7 +38,6 @@ const auditLogBuffer: Array<{
metadata: any; metadata: any;
action: boolean; action: boolean;
resourceId?: number; resourceId?: number;
siteResourceId?: number;
reason: number; reason: number;
location?: string; location?: string;
originalRequestURL: string; originalRequestURL: string;
@@ -188,7 +186,6 @@ export async function logRequestAudit(
action: boolean; action: boolean;
reason: number; reason: number;
resourceId?: number; resourceId?: number;
siteResourceId?: number;
orgId?: string; orgId?: string;
location?: string; location?: string;
user?: { username: string; userId: string }; user?: { username: string; userId: string };
@@ -265,7 +262,6 @@ export async function logRequestAudit(
metadata: sanitizeString(metadata), metadata: sanitizeString(metadata),
action: data.action, action: data.action,
resourceId: data.resourceId, resourceId: data.resourceId,
siteResourceId: data.siteResourceId,
reason: data.reason, reason: data.reason,
location: sanitizeString(data.location), location: sanitizeString(data.location),
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",

View File

@@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTarget = await generateSubnetProxyTargetV2( const resourceTarget = generateSubnetProxyTargetV2(
resource, resource,
resourceClients resourceClients
); );

View File

@@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
logger.warn( logger.warn(
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
); );
return; return;
} }

View File

@@ -1,9 +0,0 @@
import { MessageHandler } from "@server/routers/ws";
export async function flushRequestLogToDb(): Promise<void> {
return;
}
export const handleRequestLogMessage: MessageHandler = async (context) => {
return;
};

View File

@@ -9,5 +9,4 @@ export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage"; export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage"; export * from "./handleNewtDisconnectingMessage";
export * from "./handleConnectionLogMessage"; export * from "./handleConnectionLogMessage";
export * from "./handleRequestLogMessage";
export * from "./registerNewt"; export * from "./registerNewt";

View File

@@ -1,6 +1,6 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, clients, olms } from "@server/db"; import { sites, clients, olms } from "@server/db";
import { inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
/** /**
@@ -21,7 +21,7 @@ import logger from "@server/logger";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5; const MAX_RETRIES = 2;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
@@ -36,14 +36,6 @@ const pendingOlmArchiveResets: Set<string> = new Set();
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
/**
* Guard that prevents two flush cycles from running concurrently.
* setInterval does not await async callbacks, so without this a slow flush
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
* the two concurrent bulk UPDATEs would deadlock each other.
*/
let isFlushing = false;
// ── Public API ───────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────
/** /**
@@ -80,12 +72,6 @@ export function recordClientPing(
/** /**
* Flush all accumulated site pings to the database. * Flush all accumulated site pings to the database.
*
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
* statement. We use the maximum timestamp across the batch so that `lastPing`
* reflects the most recent ping seen for any site in the group. This avoids
* the multi-statement transaction that previously created additional
* row-lock ordering hazards.
*/ */
async function flushSitePingsToDb(): Promise<void> { async function flushSitePingsToDb(): Promise<void> {
if (pendingSitePings.size === 0) { if (pendingSitePings.size === 0) {
@@ -97,35 +83,55 @@ async function flushSitePingsToDb(): Promise<void> {
const pingsToFlush = new Map(pendingSitePings); const pingsToFlush = new Map(pendingSitePings);
pendingSitePings.clear(); pendingSitePings.clear();
const entries = Array.from(pingsToFlush.entries()); // Sort by siteId for consistent lock ordering (prevents deadlocks)
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < entries.length; i += BATCH_SIZE) { for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE); const batch = sortedEntries.slice(i, i + BATCH_SIZE);
// Use the latest timestamp in the batch so that `lastPing` always
// moves forward. Using a single timestamp for the whole batch means
// we only ever need one UPDATE statement (no transaction).
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const siteIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
// Group by timestamp for efficient bulk updates
const byTimestamp = new Map<number, number[]>();
for (const [siteId, timestamp] of batch) {
const group = byTimestamp.get(timestamp) || [];
group.push(siteId);
byTimestamp.set(timestamp, group);
}
if (byTimestamp.size === 1) {
const [timestamp, siteIds] = Array.from(
byTimestamp.entries()
)[0];
await db await db
.update(sites) .update(sites)
.set({ .set({
online: true, online: true,
lastPing: maxTimestamp lastPing: timestamp
}) })
.where(inArray(sites.siteId, siteIds)); .where(inArray(sites.siteId, siteIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, siteIds] of byTimestamp) {
await tx
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
}
});
}
}, "flushSitePingsToDb"); }, "flushSitePingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
{ error } { error }
); );
// Re-queue only if the preserved timestamp is newer than any
// update that may have landed since we snapshotted.
for (const [siteId, timestamp] of batch) { for (const [siteId, timestamp] of batch) {
const existing = pendingSitePings.get(siteId); const existing = pendingSitePings.get(siteId);
if (!existing || existing < timestamp) { if (!existing || existing < timestamp) {
@@ -138,8 +144,6 @@ async function flushSitePingsToDb(): Promise<void> {
/** /**
* Flush all accumulated client (OLM) pings to the database. * Flush all accumulated client (OLM) pings to the database.
*
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
*/ */
async function flushClientPingsToDb(): Promise<void> { async function flushClientPingsToDb(): Promise<void> {
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) { if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
@@ -155,25 +159,51 @@ async function flushClientPingsToDb(): Promise<void> {
// ── Flush client pings ───────────────────────────────────────────── // ── Flush client pings ─────────────────────────────────────────────
if (pingsToFlush.size > 0) { if (pingsToFlush.size > 0) {
const entries = Array.from(pingsToFlush.entries()); const sortedEntries = Array.from(pingsToFlush.entries()).sort(
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < entries.length; i += BATCH_SIZE) { for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE); const batch = sortedEntries.slice(i, i + BATCH_SIZE);
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
const clientIds = batch.map(([id]) => id);
try { try {
await withRetry(async () => { await withRetry(async () => {
const byTimestamp = new Map<number, number[]>();
for (const [clientId, timestamp] of batch) {
const group = byTimestamp.get(timestamp) || [];
group.push(clientId);
byTimestamp.set(timestamp, group);
}
if (byTimestamp.size === 1) {
const [timestamp, clientIds] = Array.from(
byTimestamp.entries()
)[0];
await db await db
.update(clients) .update(clients)
.set({ .set({
lastPing: maxTimestamp, lastPing: timestamp,
online: true, online: true,
archived: false archived: false
}) })
.where(inArray(clients.clientId, clientIds)); .where(inArray(clients.clientId, clientIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, clientIds] of byTimestamp) {
await tx
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(
inArray(clients.clientId, clientIds)
);
}
});
}
}, "flushClientPingsToDb"); }, "flushClientPingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -230,12 +260,7 @@ export async function flushPingsToDb(): Promise<void> {
/** /**
* Simple retry wrapper with exponential backoff for transient errors * Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects). * (connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/ */
async function withRetry<T>( async function withRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
@@ -252,8 +277,7 @@ async function withRetry<T>(
const jitter = Math.random() * baseDelay; const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`, `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
{ code: error?.code ?? error?.cause?.code }
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;
@@ -264,14 +288,14 @@ async function withRetry<T>(
} }
/** /**
* Detect transient errors that are safe to retry. * Detect transient connection errors that are safe to retry.
*/ */
function isTransientError(error: any): boolean { function isTransientError(error: any): boolean {
if (!error) return false; if (!error) return false;
const message = (error.message || "").toLowerCase(); const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase(); const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || ""; const code = error.code || "";
// Connection timeout / terminated // Connection timeout / terminated
if ( if (
@@ -284,17 +308,12 @@ function isTransientError(error: any): boolean {
return true; return true;
} }
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed) // PostgreSQL deadlock
if (code === "40P01" || message.includes("deadlock")) { if (code === "40P01" || message.includes("deadlock")) {
return true; return true;
} }
// PostgreSQL serialization failure // ECONNRESET, ECONNREFUSED, EPIPE
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if ( if (
code === "ECONNRESET" || code === "ECONNRESET" ||
code === "ECONNREFUSED" || code === "ECONNREFUSED" ||
@@ -318,26 +337,12 @@ export function startPingAccumulator(): void {
} }
flushTimer = setInterval(async () => { flushTimer = setInterval(async () => {
// Skip this tick if the previous flush is still in progress.
// setInterval does not await async callbacks, so without this guard
// two flush cycles can run concurrently and deadlock each other on
// overlapping bulk UPDATE statements.
if (isFlushing) {
logger.debug(
"Ping accumulator: previous flush still in progress, skipping cycle"
);
return;
}
isFlushing = true;
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {
logger.error("Unhandled error in ping accumulator flush", { logger.error("Unhandled error in ping accumulator flush", {
error error
}); });
} finally {
isFlushing = false;
} }
}, FLUSH_INTERVAL_MS); }, FLUSH_INTERVAL_MS);
@@ -359,22 +364,7 @@ export async function stopPingAccumulator(): Promise<void> {
flushTimer = null; flushTimer = null;
} }
// Final flush to persist any remaining pings. // Final flush to persist any remaining pings
// Wait for any in-progress flush to finish first so we don't race.
if (isFlushing) {
logger.debug(
"Ping accumulator: waiting for in-progress flush before stopping…"
);
await new Promise<void>((resolve) => {
const poll = setInterval(() => {
if (!isFlushing) {
clearInterval(poll);
resolve();
}
}, 50);
});
}
try { try {
await flushPingsToDb(); await flushPingsToDb();
} catch (error) { } catch (error) {

View File

@@ -17,6 +17,7 @@ import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip";
import { build } from "@server/build"; import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";

View File

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

View File

@@ -17,7 +17,7 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -28,7 +28,6 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -37,12 +36,11 @@ const createSiteResourceParamsSchema = z.strictObject({
const createSiteResourceSchema = z const createSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr", "port"]),
ssl: z.boolean().optional(), // only used for http mode
siteId: z.int(), siteId: z.int(),
scheme: z.enum(["http", "https"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
alias: z alias: z
@@ -59,15 +57,12 @@ const createSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(), authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional(), authDaemonMode: z.enum(["site", "remote"]).optional()
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
}) })
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host") { if (data.mode === "host") {
if (data.mode == "host") {
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
@@ -77,7 +72,6 @@ const createSiteResourceSchema = z
if (isValidIP) { if (isValidIP) {
return true; return true;
} }
}
// Check if it's a valid domain (hostname pattern, TLD not required) // Check if it's a valid domain (hostname pattern, TLD not required)
const domainRegex = const domainRegex =
@@ -111,21 +105,6 @@ const createSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" 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>; export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
@@ -182,12 +161,11 @@ export async function createSiteResource(
name, name,
siteId, siteId,
mode, mode,
scheme, // protocol,
// proxyPort, // proxyPort,
destinationPort, // destinationPort,
destination, destination,
enabled, enabled,
ssl,
alias, alias,
userIds, userIds,
roleIds, roleIds,
@@ -196,26 +174,9 @@ export async function createSiteResource(
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode, authDaemonMode
domainId,
subdomain
} = parsedBody.data; } = parsedBody.data;
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const [site] = await db const [site] = await db
.select() .select()
@@ -265,50 +226,29 @@ export async function createSiteResource(
); );
} }
if (domainId && alias) { // // check if resource with same protocol and proxy port already exists (only for port mode)
// throw an error because we can only have one or the other // if (mode === "port" && protocol && proxyPort) {
return next( // const [existingResource] = await db
createHttpError( // .select()
HttpCode.BAD_REQUEST, // .from(siteResources)
"Alias and domain cannot both be set. Please choose one or the other." // .where(
) // and(
); // eq(siteResources.siteId, siteId),
} // eq(siteResources.orgId, orgId),
// eq(siteResources.protocol, protocol),
let fullDomain: string | null = null; // eq(siteResources.proxyPort, proxyPort)
let finalSubdomain: string | null = null; // )
if (domainId) { // )
// Validate domain and construct full domain // .limit(1);
const domainResult = await validateAndConstructDomain( // if (existingResource && existingResource.siteResourceId) {
domainId, // return next(
orgId, // createHttpError(
subdomain // HttpCode.CONFLICT,
); // "A resource with the same protocol and proxy port already exists"
// )
if (!domainResult.success) { // );
return next( // }
createHttpError(HttpCode.BAD_REQUEST, domainResult.error) // }
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const existingResource = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
if (alias) { if (alias) {
@@ -340,7 +280,8 @@ export async function createSiteResource(
const niceId = await getUniqueSiteResourceName(orgId); const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null; 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); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
@@ -352,20 +293,14 @@ export async function createSiteResource(
niceId, niceId,
orgId, orgId,
name, name,
mode, mode: mode as "host" | "cidr",
ssl,
destination, destination,
scheme,
destinationPort,
enabled, enabled,
alias: alias ? alias.trim() : null, alias,
aliasAddress, aliasAddress,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp
domainId,
subdomain: finalSubdomain,
fullDomain
}; };
if (isLicensedSshPam) { if (isLicensedSshPam) {
if (authDaemonPort !== undefined) if (authDaemonPort !== undefined)

View File

@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
mode: z mode: z
.enum(["host", "cidr", "http"]) .enum(["host", "cidr"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["host", "cidr", "http"], enum: ["host", "cidr"],
description: "Filter site resources by mode" description: "Filter site resources by mode"
}), }),
sort_by: z sort_by: z
@@ -76,7 +76,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteName: string; siteName: string;
siteNiceId: string; siteNiceId: string;
siteAddress: string | null; siteAddress: string | null;
siteOnline: boolean;
})[]; })[];
}>; }>;
@@ -89,8 +88,7 @@ function querySiteResourcesBase() {
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
mode: siteResources.mode, mode: siteResources.mode,
ssl: siteResources.ssl, protocol: siteResources.protocol,
scheme: siteResources.scheme,
proxyPort: siteResources.proxyPort, proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort, destinationPort: siteResources.destinationPort,
destination: siteResources.destination, destination: siteResources.destination,
@@ -102,13 +100,9 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode, authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort, authDaemonPort: siteResources.authDaemonPort,
subdomain: siteResources.subdomain,
domainId: siteResources.domainId,
fullDomain: siteResources.fullDomain,
siteName: sites.name, siteName: sites.name,
siteNiceId: sites.niceId, siteNiceId: sites.niceId,
siteAddress: sites.address, siteAddress: sites.address
siteOnline: sites.online
}) })
.from(siteResources) .from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId)); .innerJoin(sites, eq(siteResources.siteId, sites.siteId));
@@ -199,9 +193,7 @@ export async function listAllSiteResourcesByOrg(
const baseQuery = querySiteResourcesBase().where(and(...conditions)); const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count( const countQuery = db.$count(
querySiteResourcesBase() querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
.where(and(...conditions))
.as("filtered_site_resources")
); );
const [siteResourcesList, totalCount] = await Promise.all([ const [siteResourcesList, totalCount] = await Promise.all([

View File

@@ -1,3 +1,4 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { import {
clientSiteResources, clientSiteResources,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
@@ -12,9 +13,7 @@ import {
Transaction, Transaction,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
@@ -52,11 +51,10 @@ const updateSiteResourceSchema = z
) )
.optional(), .optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http"]).optional(), mode: z.enum(["host", "cidr"]).optional(),
ssl: z.boolean().optional(), // protocol: z.enum(["tcp", "udp"]).nullish(),
scheme: z.enum(["http", "https"]).nullish(),
// proxyPort: z.int().positive().nullish(), // proxyPort: z.int().positive().nullish(),
destinationPort: z.int().positive().nullish(), // destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
alias: z alias: z
@@ -73,9 +71,7 @@ const updateSiteResourceSchema = z
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(), authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional(), authDaemonMode: z.enum(["site", "remote"]).optional()
domainId: z.string().optional(),
subdomain: z.string().optional()
}) })
.strict() .strict()
.refine( .refine(
@@ -122,23 +118,6 @@ const updateSiteResourceSchema = z
{ {
message: "Destination must be a valid CIDR notation for cidr mode" 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>; export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
@@ -196,11 +175,8 @@ export async function updateSiteResource(
siteId, // because it can change siteId, // because it can change
niceId, niceId,
mode, mode,
scheme,
destination, destination,
destinationPort,
alias, alias,
ssl,
enabled, enabled,
userIds, userIds,
roleIds, roleIds,
@@ -209,9 +185,7 @@ export async function updateSiteResource(
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
authDaemonPort, authDaemonPort,
authDaemonMode, authDaemonMode
domainId,
subdomain
} = parsedBody.data; } = parsedBody.data;
const [site] = await db const [site] = await db
@@ -237,21 +211,6 @@ export async function updateSiteResource(
); );
} }
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix[TierFeature.HTTPPrivateResources]
);
if (!hasHttpFeature) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"HTTP private resources are not included in your current plan. Please upgrade."
)
);
}
}
const isLicensedSshPam = await isLicensedOrSubscribed( const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId, existingSiteResource.orgId,
tierMatrix.sshPam tierMatrix.sshPam
@@ -316,45 +275,6 @@ export async function updateSiteResource(
} }
} }
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
domainId,
org.orgId,
subdomain
);
if (!domainResult.success) {
return next(
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
);
}
fullDomain = domainResult.fullDomain;
finalSubdomain = domainResult.subdomain;
// make sure the full domain is unique
const [existingDomain] = await db
.select()
.from(siteResources)
.where(eq(siteResources.fullDomain, fullDomain));
if (
existingDomain &&
existingDomain.siteResourceId !==
existingSiteResource.siteResourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
if (alias) { if (alias) {
const [conflict] = await db const [conflict] = await db
@@ -426,18 +346,12 @@ export async function updateSiteResource(
siteId, siteId,
niceId, niceId,
mode, mode,
scheme,
ssl,
destination, destination,
destinationPort,
enabled, enabled,
alias: alias ? alias.trim() : null, alias: alias && alias.trim() ? alias : null,
tcpPortRangeString, tcpPortRangeString,
udpPortRangeString, udpPortRangeString,
disableIcmp, disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet ...sshPamSet
}) })
.where( .where(
@@ -534,20 +448,13 @@ export async function updateSiteResource(
.set({ .set({
name: name, name: name,
siteId: siteId, siteId: siteId,
niceId: niceId,
mode: mode, mode: mode,
scheme,
ssl,
destination: destination, destination: destination,
destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias ? alias.trim() : null, alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString, tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString, udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp, disableIcmp: disableIcmp,
domainId,
subdomain: finalSubdomain,
fullDomain,
...sshPamSet ...sshPamSet
}) })
.where( .where(
@@ -682,14 +589,9 @@ export async function handleMessagingForUpdatedSiteResource(
const destinationChanged = const destinationChanged =
existingSiteResource && existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination; existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged = const aliasChanged =
existingSiteResource && existingSiteResource &&
(existingSiteResource.alias !== updatedSiteResource.alias || existingSiteResource.alias !== updatedSiteResource.alias;
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias
const portRangesChanged = const portRangesChanged =
existingSiteResource && existingSiteResource &&
(existingSiteResource.tcpPortRangeString !== (existingSiteResource.tcpPortRangeString !==
@@ -701,7 +603,7 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) { if (destinationChanged || aliasChanged || portRangesChanged) {
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
@@ -715,12 +617,12 @@ export async function handleMessagingForUpdatedSiteResource(
} }
// Only update targets on newt if destination changed // Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged || destinationPortChanged) { if (destinationChanged || portRangesChanged) {
const oldTarget = await generateSubnetProxyTargetV2( const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients
); );
const newTarget = await generateSubnetProxyTargetV2( const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource, updatedSiteResource,
mergedAllClients mergedAllClients
); );

View File

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

View File

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

@@ -471,7 +471,11 @@ export default function GeneralPage() {
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}` : `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
} }
> >
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>

View File

@@ -451,7 +451,11 @@ export default function ConnectionLogsPage() {
<Link <Link
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`} href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
> >
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
@@ -493,7 +497,11 @@ export default function ConnectionLogsPage() {
<Link <Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`} href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
> >
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
className="text-xs h-6"
>
<Laptop className="mr-1 h-3 w-3" /> <Laptop className="mr-1 h-3 w-3" />
{row.original.clientName} {row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
@@ -667,7 +675,9 @@ export default function ConnectionLogsPage() {
<div> <div>
<strong>Ended At:</strong>{" "} <strong>Ended At:</strong>{" "}
{row.endedAt {row.endedAt
? new Date(row.endedAt * 1000).toLocaleString() ? new Date(
row.endedAt * 1000
).toLocaleString()
: "Active"} : "Active"}
</div> </div>
<div> <div>

View File

@@ -360,7 +360,6 @@ export default function GeneralPage() {
// 105 - Valid Password // 105 - Valid Password
// 106 - Valid email // 106 - Valid email
// 107 - Valid SSO // 107 - Valid SSO
// 108 - Connected Client
// 201 - Resource Not Found // 201 - Resource Not Found
// 202 - Resource Blocked // 202 - Resource Blocked
@@ -378,7 +377,6 @@ export default function GeneralPage() {
105: t("validPassword"), 105: t("validPassword"),
106: t("validEmail"), 106: t("validEmail"),
107: t("validSSO"), 107: t("validSSO"),
108: t("connectedClient"),
201: t("resourceNotFound"), 201: t("resourceNotFound"),
202: t("resourceBlocked"), 202: t("resourceBlocked"),
203: t("droppedByRule"), 203: t("droppedByRule"),
@@ -512,14 +510,14 @@ export default function GeneralPage() {
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Link <Link
href={ href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
className="text-xs h-6"
>
{row.original.resourceName} {row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-3 w-3" />
</Button> </Button>
@@ -636,7 +634,6 @@ export default function GeneralPage() {
{ value: "105", label: t("validPassword") }, { value: "105", label: t("validPassword") },
{ value: "106", label: t("validEmail") }, { value: "106", label: t("validEmail") },
{ value: "107", label: t("validSSO") }, { value: "107", label: t("validSSO") },
{ value: "108", label: t("connectedClient") },
{ value: "201", label: t("resourceNotFound") }, { value: "201", label: t("resourceNotFound") },
{ value: "202", label: t("resourceBlocked") }, { value: "202", label: t("resourceBlocked") },
{ value: "203", label: t("droppedByRule") }, { value: "203", label: t("droppedByRule") },

View File

@@ -56,39 +56,18 @@ export default async function ClientResourcesPage(
const internalResourceRows: InternalResourceRow[] = siteResources.map( const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => { (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 { return {
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
sites: [
{
siteId: siteResource.siteId,
siteName: siteResource.siteName,
siteNiceId: siteResource.siteNiceId,
online: siteResource.siteOnline
}
],
siteName: siteResource.siteName, siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null, siteAddress: siteResource.siteAddress || null,
mode: normalizedMode, mode: siteResource.mode || ("port" as any),
scheme:
siteResource.scheme ??
(rawMode === "https" ? ("https" as const) : null),
ssl: siteResource.ssl === true || rawMode === "https",
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId, siteId: siteResource.siteId,
destination: siteResource.destination, destination: siteResource.destination,
httpHttpsPort: siteResource.destinationPort ?? null, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId, siteNiceId: siteResource.siteNiceId,
@@ -97,10 +76,7 @@ export default async function ClientResourcesPage(
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false, disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null, authDaemonPort: siteResource.authDaemonPort ?? null
subdomain: siteResource.subdomain ?? null,
domainId: siteResource.domainId ?? null,
fullDomain: siteResource.fullDomain ?? null
}; };
} }
); );

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

@@ -20,7 +20,7 @@ import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown, ArrowUpDown,
ChevronDown, ArrowUpRight,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
@@ -38,32 +38,21 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { cn } from "@app/lib/cn";
export type InternalResourceSiteRow = {
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
};
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
sites: InternalResourceSiteRow[];
siteName: string; siteName: string;
siteAddress: string | null; siteAddress: string | null;
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http"; mode: "host" | "cidr";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null; // protocol: string | null;
// proxyPort: number | null; // proxyPort: number | null;
siteId: number; siteId: number;
siteNiceId: string; siteNiceId: string;
destination: string; destination: string;
httpHttpsPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
niceId: string; niceId: string;
@@ -72,147 +61,8 @@ export type InternalResourceRow = {
disableIcmp: boolean; disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | 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 AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
function aggregateSitesStatus(
resourceSites: InternalResourceSiteRow[]
): AggregateSitesStatus {
if (resourceSites.length === 0) {
return "allOffline";
}
const onlineCount = resourceSites.filter((rs) => rs.online).length;
if (onlineCount === resourceSites.length) return "allOnline";
if (onlineCount > 0) return "partial";
return "allOffline";
}
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
switch (status) {
case "allOnline":
return "bg-green-500";
case "partial":
return "bg-yellow-500";
case "allOffline":
default:
return "bg-gray-500";
}
}
function ClientResourceSitesStatusCell({
orgId,
resourceSites
}: {
orgId: string;
resourceSites: InternalResourceSiteRow[];
}) {
const t = useTranslations();
if (resourceSites.length === 0) {
return <span>-</span>;
}
const aggregate = aggregateSitesStatus(resourceSites);
const countLabel = t("multiSitesSelectorSitesCount", {
count: resourceSites.length
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex h-8 items-center gap-2 px-0 font-normal"
>
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
aggregateStatusDotClass(aggregate)
)}
/>
<span className="text-sm tabular-nums">{countLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
{resourceSites.map((site) => {
const isOnline = site.online;
return (
<DropdownMenuItem key={site.siteId} asChild>
<Link
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
className="flex cursor-pointer items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"h-2 w-2 shrink-0 rounded-full",
isOnline
? "bg-green-500"
: "bg-gray-500"
)}
/>
<span className="truncate">
{site.siteName}
</span>
</div>
<span
className={cn(
"shrink-0 capitalize",
isOnline
? "text-green-600"
: "text-muted-foreground"
)}
>
{isOnline ? t("online") : t("offline")}
</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -247,6 +97,8 @@ export default function ClientResourcesTable({
useState<InternalResourceRow | null>(); useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const refreshData = () => { const refreshData = () => {
@@ -333,18 +185,20 @@ export default function ClientResourcesTable({
} }
}, },
{ {
id: "sites", accessorKey: "siteName",
accessorFn: (row) => friendlyName: t("site"),
row.sites.map((s) => s.siteName).join(", ") || row.siteName, header: () => <span className="p-3">{t("site")}</span>,
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
<ClientResourceSitesStatusCell <Link
orgId={resourceRow.orgId} href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
resourceSites={resourceRow.sites} >
/> <Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
); );
} }
}, },
@@ -361,10 +215,6 @@ export default function ClientResourcesTable({
{ {
value: "cidr", value: "cidr",
label: t("editInternalResourceDialogModeCidr") label: t("editInternalResourceDialogModeCidr")
},
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
} }
]} ]}
selectedValue={searchParams.get("mode") ?? undefined} selectedValue={searchParams.get("mode") ?? undefined}
@@ -377,14 +227,10 @@ export default function ClientResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record< const modeLabels: Record<"host" | "cidr" | "port", string> = {
"host" | "cidr" | "port" | "http",
string
> = {
host: t("editInternalResourceDialogModeHost"), host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"), cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"), port: t("editInternalResourceDialogModePort")
http: t("editInternalResourceDialogModeHttp")
}; };
return <span>{modeLabels[resourceRow.mode]}</span>; return <span>{modeLabels[resourceRow.mode]}</span>;
} }
@@ -397,12 +243,11 @@ export default function ClientResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const display = formatDestinationDisplay(resourceRow);
return ( return (
<CopyToClipboard <CopyToClipboard
text={display} text={resourceRow.destination}
isLink={false} isLink={false}
displayText={display} displayText={resourceRow.destination}
/> />
); );
} }
@@ -415,27 +260,16 @@ export default function ClientResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) { return resourceRow.mode === "host" && resourceRow.alias ? (
return (
<CopyToClipboard <CopyToClipboard
text={resourceRow.alias} text={resourceRow.alias}
isLink={false} isLink={false}
displayText={resourceRow.alias} displayText={resourceRow.alias}
/> />
) : (
<span>-</span>
); );
} }
if (resourceRow.mode === "http") {
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
return (
<CopyToClipboard
text={url}
isLink={isSafeUrlForLink(url)}
displayText={url}
/>
);
}
return <span>-</span>;
}
}, },
{ {
accessorKey: "aliasAddress", accessorKey: "aliasAddress",
@@ -601,6 +435,7 @@ export default function ClientResourcesTable({
setOpen={setIsEditDialogOpen} setOpen={setIsEditDialogOpen}
resource={editingResource} resource={editingResource}
orgId={orgId} orgId={orgId}
sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly // Delay refresh to allow modal to close smoothly
setTimeout(() => { setTimeout(() => {
@@ -615,6 +450,7 @@ export default function ClientResourcesTable({
open={isCreateDialogOpen} open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen} setOpen={setIsCreateDialogOpen}
orgId={orgId} orgId={orgId}
sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly // Delay refresh to allow modal to close smoothly
setTimeout(() => { setTimeout(() => {

View File

@@ -31,6 +31,7 @@ type CreateInternalResourceDialogProps = {
open: boolean; open: boolean;
setOpen: (val: boolean) => void; setOpen: (val: boolean) => void;
orgId: string; orgId: string;
sites: Site[];
onSuccess?: () => void; onSuccess?: () => void;
}; };
@@ -38,21 +39,18 @@ export default function CreateInternalResourceDialog({
open, open,
setOpen, setOpen,
orgId, orgId,
sites,
onSuccess onSuccess
}: CreateInternalResourceDialogProps) { }: CreateInternalResourceDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
let data = { ...values }; let data = { ...values };
if ( if (data.mode === "host" && isHostname(data.destination)) {
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
if (!currentAlias) { if (!currentAlias) {
let aliasValue = data.destination; let aliasValue = data.destination;
@@ -67,56 +65,25 @@ export default function CreateInternalResourceDialog({
`/org/${orgId}/site-resource`, `/org/${orgId}/site-resource`,
{ {
name: data.name, name: data.name,
siteId: data.siteIds[0], siteId: data.siteId,
mode: data.mode, mode: data.mode,
destination: data.destination, destination: data.destination,
enabled: true, enabled: true,
...(data.mode === "http" && { alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? undefined,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" || data.mode == "cidr") && {
tcpPortRangeString: data.tcpPortRangeString, tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString, udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false disableIcmp: data.disableIcmp ?? false,
}), ...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
roleIds: data.roles ...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
? data.roles.map((r) => parseInt(r.id)) roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
: [],
userIds: data.users ? data.users.map((u) => u.id) : [], userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
? data.clients.map((c) => parseInt(c.id))
: []
} }
); );
toast({ toast({
title: t("createInternalResourceDialogSuccess"), title: t("createInternalResourceDialogSuccess"),
description: t( description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default" variant: "default"
}); });
setOpen(false); setOpen(false);
@@ -126,9 +93,7 @@ export default function CreateInternalResourceDialog({
title: t("createInternalResourceDialogError"), title: t("createInternalResourceDialogError"),
description: formatAxiosError( description: formatAxiosError(
error, error,
t( t("createInternalResourceDialogFailedToCreateInternalResource")
"createInternalResourceDialogFailedToCreateInternalResource"
)
), ),
variant: "destructive" variant: "destructive"
}); });
@@ -141,39 +106,31 @@ export default function CreateInternalResourceDialog({
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl"> <CredenzaContent className="max-w-3xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle> <CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t( {t("createInternalResourceDialogCreateClientResourceDescription")}
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<InternalResourceForm <InternalResourceForm
variant="create" variant="create"
open={open} open={open}
sites={sites}
orgId={orgId} orgId={orgId}
formId="create-internal-resource-form" formId="create-internal-resource-form"
onSubmit={handleSubmit} onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
/> />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button <Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")} {t("createInternalResourceDialogCancel")}
</Button> </Button>
</CredenzaClose> </CredenzaClose>
<Button <Button
type="submit" type="submit"
form="create-internal-resource-form" form="create-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
> >
{t("createInternalResourceDialogCreateResource")} {t("createInternalResourceDialogCreateResource")}

View File

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

View File

@@ -34,6 +34,7 @@ type EditInternalResourceDialogProps = {
setOpen: (val: boolean) => void; setOpen: (val: boolean) => void;
resource: InternalResourceData; resource: InternalResourceData;
orgId: string; orgId: string;
sites: Site[];
onSuccess?: () => void; onSuccess?: () => void;
}; };
@@ -42,21 +43,18 @@ export default function EditInternalResourceDialog({
setOpen, setOpen,
resource, resource,
orgId, orgId,
sites,
onSuccess onSuccess
}: EditInternalResourceDialogProps) { }: EditInternalResourceDialogProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
try { try {
let data = { ...values }; let data = { ...values };
if ( if (data.mode === "host" && isHostname(data.destination)) {
(data.mode === "host" || data.mode === "http") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || ""; const currentAlias = data.alias?.trim() || "";
if (!currentAlias) { if (!currentAlias) {
let aliasValue = data.destination; let aliasValue = data.destination;
@@ -69,39 +67,24 @@ export default function EditInternalResourceDialog({
await api.post(`/site-resource/${resource.id}`, { await api.post(`/site-resource/${resource.id}`, {
name: data.name, name: data.name,
siteId: data.siteIds[0], siteId: data.siteId,
mode: data.mode, mode: data.mode,
niceId: data.niceId, niceId: data.niceId,
destination: data.destination, destination: data.destination,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.httpHttpsPort ?? null,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias: alias:
data.alias && data.alias &&
typeof data.alias === "string" && typeof data.alias === "string" &&
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: null, : null,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
...(data.authDaemonMode != null && { ...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode authDaemonMode: data.authDaemonMode
}), }),
...(data.authDaemonMode === "remote" && { ...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}), }),
roleIds: (data.roles || []).map((r) => parseInt(r.id)), roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id), userIds: (data.users || []).map((u) => u.id),
@@ -173,13 +156,13 @@ export default function EditInternalResourceDialog({
variant="edit" variant="edit"
open={open} open={open}
resource={resource} resource={resource}
sites={sites}
orgId={orgId} orgId={orgId}
siteResourceId={resource.id} siteResourceId={resource.id}
formId="edit-internal-resource-form" formId="edit-internal-resource-form"
onSubmit={(values) => onSubmit={(values) =>
startTransition(() => handleSubmit(values)) startTransition(() => handleSubmit(values))
} }
onSubmitDisabledChange={setIsHttpModeDisabled}
/> />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
@@ -195,7 +178,7 @@ export default function EditInternalResourceDialog({
<Button <Button
type="submit" type="submit"
form="edit-internal-resource-form" form="edit-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
> >
{t("editInternalResourceDialogSaveResource")} {t("editInternalResourceDialogSaveResource")}

View File

@@ -1,10 +1,6 @@
"use client"; "use client";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { StrategySelect } from "@app/components/StrategySelect"; import { StrategySelect } from "@app/components/StrategySelect";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
@@ -46,15 +42,9 @@ import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { import { SitesSelector, type Selectedsite } from "./site-selector";
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import type { Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -130,14 +120,12 @@ export const cleanForFQDN = (name: string): string =>
type Site = ListSitesResponse["sites"][0]; type Site = ListSitesResponse["sites"][0];
export type InternalResourceMode = "host" | "cidr" | "http";
export type InternalResourceData = { export type InternalResourceData = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
siteName: string; siteName: string;
mode: InternalResourceMode; mode: "host" | "cidr";
siteId: number; siteId: number;
niceId: string; niceId: string;
destination: string; destination: string;
@@ -147,32 +135,14 @@ export type InternalResourceData = {
disableIcmp?: boolean; disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https" | null;
ssl?: boolean;
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
}; };
const tagSchema = z.object({ id: z.string(), text: z.string() }); const tagSchema = z.object({ id: z.string(), text: z.string() });
function buildSelectedSitesForResource(
resource: InternalResourceData,
): Selectedsite[] {
return [
{
name: resource.siteName,
siteId: resource.siteId,
type: "newt"
}
];
}
export type InternalResourceFormValues = { export type InternalResourceFormValues = {
name: string; name: string;
siteIds: number[]; siteId: number;
mode: InternalResourceMode; mode: "host" | "cidr";
destination: string; destination: string;
alias?: string | null; alias?: string | null;
niceId?: string; niceId?: string;
@@ -181,12 +151,6 @@ export type InternalResourceFormValues = {
disableIcmp?: boolean; disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | 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>[]; roles?: z.infer<typeof tagSchema>[];
users?: z.infer<typeof tagSchema>[]; users?: z.infer<typeof tagSchema>[];
clients?: z.infer<typeof tagSchema>[]; clients?: z.infer<typeof tagSchema>[];
@@ -196,29 +160,28 @@ type InternalResourceFormProps = {
variant: "create" | "edit"; variant: "create" | "edit";
resource?: InternalResourceData; resource?: InternalResourceData;
open?: boolean; open?: boolean;
sites: Site[];
orgId: string; orgId: string;
siteResourceId?: number; siteResourceId?: number;
formId: string; formId: string;
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>; onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
onSubmitDisabledChange?: (disabled: boolean) => void;
}; };
export function InternalResourceForm({ export function InternalResourceForm({
variant, variant,
resource, resource,
open, open,
sites,
orgId, orgId,
siteResourceId, siteResourceId,
formId, formId,
onSubmit, onSubmit
onSubmitDisabledChange
}: InternalResourceFormProps) { }: InternalResourceFormProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam); const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
const nameRequiredKey = const nameRequiredKey =
variant === "create" variant === "create"
@@ -248,22 +211,6 @@ export function InternalResourceForm({
variant === "create" variant === "create"
? "createInternalResourceDialogModeCidr" ? "createInternalResourceDialogModeCidr"
: "editInternalResourceDialogModeCidr"; : "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 = const destinationLabelKey =
variant === "create" variant === "create"
? "createInternalResourceDialogDestination" ? "createInternalResourceDialogDestination"
@@ -276,28 +223,14 @@ export function InternalResourceForm({
variant === "create" variant === "create"
? "createInternalResourceDialogAlias" ? "createInternalResourceDialogAlias"
: "editInternalResourceDialogAlias"; : "editInternalResourceDialogAlias";
const httpHttpsPortLabelKey =
variant === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const httpConfigurationTitleKey =
variant === "create"
? "createInternalResourceDialogHttpConfiguration"
: "editInternalResourceDialogHttpConfiguration";
const httpConfigurationDescriptionKey =
variant === "create"
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const siteIdsSchema = siteRequiredKey const formSchema = z.object({
? z.array(z.number().int().positive()).min(1, t(siteRequiredKey))
: z.array(z.number().int().positive()).min(1);
const formSchema = z
.object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteIds: siteIdsSchema, siteId: z
mode: z.enum(["host", "cidr", "http"]), .number()
.int()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
mode: z.enum(["host", "cidr"]),
destination: z destination: z
.string() .string()
.min( .min(
@@ -307,18 +240,6 @@ export function InternalResourceForm({
: undefined : undefined
), ),
alias: z.string().nullish(), 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 niceId: z
.string() .string()
.min(1) .min(1)
@@ -340,31 +261,12 @@ export function InternalResourceForm({
}) })
) )
.optional() .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>; type FormData = z.infer<typeof formSchema>;
const availableSites = sites.filter((s) => s.type === "newt");
const rolesQuery = useQuery(orgQueries.roles({ orgId })); const rolesQuery = useQuery(orgQueries.roles({ orgId }));
const usersQuery = useQuery(orgQueries.users({ orgId })); const usersQuery = useQuery(orgQueries.users({ orgId }));
const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
@@ -483,7 +385,7 @@ export function InternalResourceForm({
variant === "edit" && resource variant === "edit" && resource
? { ? {
name: resource.name, name: resource.name,
siteIds: [resource.siteId], siteId: resource.siteId,
mode: resource.mode ?? "host", mode: resource.mode ?? "host",
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
@@ -492,12 +394,6 @@ export function InternalResourceForm({
disableIcmp: resource.disableIcmp ?? false, disableIcmp: resource.disableIcmp ?? false,
authDaemonMode: resource.authDaemonMode ?? "site", authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonPort: resource.authDaemonPort ?? null, authDaemonPort: resource.authDaemonPort ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.subdomain ?? null,
httpConfigDomainId: resource.domainId ?? null,
httpConfigFullDomain: resource.fullDomain ?? null,
niceId: resource.niceId, niceId: resource.niceId,
roles: [], roles: [],
users: [], users: [],
@@ -505,16 +401,10 @@ export function InternalResourceForm({
} }
: { : {
name: "", name: "",
siteIds: [], siteId: availableSites[0]?.siteId ?? 0,
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*", tcpPortRangeString: "*",
udpPortRangeString: "*", udpPortRangeString: "*",
disableIcmp: false, disableIcmp: false,
@@ -525,10 +415,8 @@ export function InternalResourceForm({
clients: [] clients: []
}; };
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>(() => const [selectedSite, setSelectedSite] = useState<Selectedsite>(
variant === "edit" && resource availableSites[0]
? buildSelectedSitesForResource(resource)
: []
); );
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -537,10 +425,6 @@ export function InternalResourceForm({
}); });
const mode = form.watch("mode"); 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 authDaemonMode = form.watch("authDaemonMode") ?? "site";
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null); const previousResourceId = useRef<number | null>(null);
@@ -560,16 +444,10 @@ export function InternalResourceForm({
if (variant === "create" && open) { if (variant === "create" && open) {
form.reset({ form.reset({
name: "", name: "",
siteIds: [], siteId: availableSites[0]?.siteId ?? 0,
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*", tcpPortRangeString: "*",
udpPortRangeString: "*", udpPortRangeString: "*",
disableIcmp: false, disableIcmp: false,
@@ -579,13 +457,12 @@ export function InternalResourceForm({
users: [], users: [],
clients: [] clients: []
}); });
setSelectedSites([]);
setTcpPortMode("all"); setTcpPortMode("all");
setUdpPortMode("all"); setUdpPortMode("all");
setTcpCustomPorts(""); setTcpCustomPorts("");
setUdpCustomPorts(""); setUdpCustomPorts("");
} }
}, [variant, open, form]); }, [variant, open]);
// Reset when edit dialog opens / resource changes // Reset when edit dialog opens / resource changes
useEffect(() => { useEffect(() => {
@@ -594,16 +471,10 @@ export function InternalResourceForm({
if (resourceChanged) { if (resourceChanged) {
form.reset({ form.reset({
name: resource.name, name: resource.name,
siteIds: [resource.siteId], siteId: resource.siteId,
mode: resource.mode ?? "host", mode: resource.mode ?? "host",
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.subdomain ?? null,
httpConfigDomainId: resource.domainId ?? null,
httpConfigFullDomain: resource.fullDomain ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*", tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false, disableIcmp: resource.disableIcmp ?? false,
@@ -613,9 +484,6 @@ export function InternalResourceForm({
users: [], users: [],
clients: [] clients: []
}); });
setSelectedSites(
buildSelectedSitesForResource(resource)
);
setTcpPortMode( setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString) getPortModeFromString(resource.tcpPortRangeString)
); );
@@ -669,18 +537,12 @@ export function InternalResourceForm({
form form
]); ]);
useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit((values) => { onSubmit={form.handleSubmit((values) => {
const siteIds = values.siteIds;
onSubmit({ onSubmit({
...values, ...values,
siteIds,
clients: (values.clients ?? []).map((c) => ({ clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(), id: c.clientId.toString(),
text: c.name text: c.name
@@ -719,6 +581,51 @@ export function InternalResourceForm({
)} )}
/> />
)} )}
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("site")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(s) =>
s.siteId ===
field.value
)?.name
: t("selectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
filterTypes={["newt"]}
onSelectSite={(site) => {
setSelectedSite(site);
field.onChange(site.siteId);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<HorizontalTabs <HorizontalTabs
@@ -734,7 +641,7 @@ export function InternalResourceForm({
title: t("editInternalResourceDialogAccessPolicy"), title: t("editInternalResourceDialogAccessPolicy"),
href: "#" href: "#"
}, },
...(disableEnterpriseFeatures || mode !== "host" ...(disableEnterpriseFeatures || mode === "cidr"
? [] ? []
: [{ title: t("sshAccess"), href: "#" }]) : [{ title: t("sshAccess"), href: "#" }])
]} ]}
@@ -753,148 +660,46 @@ export function InternalResourceForm({
)} )}
</div> </div>
</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="siteIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("sites")}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length ===
0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<MultiSitesSelector
orgId={orgId}
selectedSites={
selectedSites
}
filterTypes={[
"newt"
]}
onSelectionChange={(
sites
) => {
setSelectedSites(
sites
);
field.onChange(
sites.map(
(s) =>
s.siteId
)
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="min-w-0 col-span-2">
<FormField
control={form.control}
name="mode"
render={({ field }) => {
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
[
{
value: "host",
label: t(modeHostKey)
},
{
value: "cidr",
label: t(modeCidrKey)
},
{
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>
);
}}
/>
</div>
</div>
<div <div
className={cn( className={cn(
"grid gap-4 items-start", "grid gap-4 items-start",
mode === "cidr" && "grid-cols-1", mode === "cidr"
mode === "http" && "grid-cols-3", ? "grid-cols-4"
mode === "host" && "grid-cols-2" : "grid-cols-12"
)} )}
> >
{mode === "http" && ( <div
<div className="min-w-0"> className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="scheme" name="mode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t(schemeLabelKey)} {t(modeLabelKey)}
</FormLabel> </FormLabel>
<Select <Select
onValueChange={ onValueChange={
field.onChange field.onChange
} }
value={ value={field.value}
field.value ??
"http"
}
disabled={httpSectionDisabled}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="http"> <SelectItem value="host">
http {t(modeHostKey)}
</SelectItem> </SelectItem>
<SelectItem value="https"> <SelectItem value="cidr">
https {t(modeCidrKey)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -903,13 +708,12 @@ export function InternalResourceForm({
)} )}
/> />
</div> </div>
)}
<div <div
className={cn( className={
mode === "cidr" && "col-span-1", mode === "cidr"
(mode === "http" || mode === "host") && ? "col-span-3"
"min-w-0" : "col-span-5"
)} }
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -920,19 +724,15 @@ export function InternalResourceForm({
{t(destinationLabelKey)} {t(destinationLabelKey)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
className="w-full"
disabled={isHttpMode && httpSectionDisabled}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
{mode === "host" && ( {mode !== "cidr" && (
<div className="min-w-0"> <div className="col-span-4">
<FormField <FormField
control={form.control} control={form.control}
name="alias" name="alias"
@@ -944,7 +744,6 @@ export function InternalResourceForm({
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="w-full"
value={ value={
field.value ?? field.value ??
"" ""
@@ -957,150 +756,9 @@ export function InternalResourceForm({
/> />
</div> </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 ??
""
}
disabled={httpSectionDisabled}
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>
</div> </div>
{isHttpMode && (
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
)}
{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>
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
<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
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
</div>
<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
}
disabled={httpSectionDisabled}
/>
</FormControl>
</FormItem>
)}
/>
</div>
) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="my-8"> <div className="my-8">
<label className="font-medium block"> <label className="font-medium block">
@@ -1148,11 +806,7 @@ export function InternalResourceForm({
value={tcpPortMode} value={tcpPortMode}
onValueChange={( onValueChange={(
v: PortMode v: PortMode
) => ) => setTcpPortMode(v)}
setTcpPortMode(
v
)
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
@@ -1161,19 +815,13 @@ export function InternalResourceForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t( {t("allPorts")}
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t( {t("blocked")}
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t( {t("custom")}
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1185,12 +833,9 @@ export function InternalResourceForm({
value={ value={
tcpCustomPorts tcpCustomPorts
} }
onChange={( onChange={(e) =>
e
) =>
setTcpCustomPorts( setTcpCustomPorts(
e e.target
.target
.value .value
) )
} }
@@ -1254,11 +899,7 @@ export function InternalResourceForm({
value={udpPortMode} value={udpPortMode}
onValueChange={( onValueChange={(
v: PortMode v: PortMode
) => ) => setUdpPortMode(v)}
setUdpPortMode(
v
)
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
@@ -1267,19 +908,13 @@ export function InternalResourceForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t( {t("allPorts")}
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t( {t("blocked")}
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t( {t("custom")}
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1291,12 +926,9 @@ export function InternalResourceForm({
value={ value={
udpCustomPorts udpCustomPorts
} }
onChange={( onChange={(e) =>
e
) =>
setUdpCustomPorts( setUdpCustomPorts(
e e.target
.target
.value .value
) )
} }
@@ -1340,9 +972,7 @@ export function InternalResourceForm({
} }
> >
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t( {t("editInternalResourceDialogIcmp")}
"editInternalResourceDialogIcmp"
)}
</FormLabel> </FormLabel>
</div> </div>
<div <div
@@ -1385,7 +1015,6 @@ export function InternalResourceForm({
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
<div className="space-y-4 mt-4 p-1"> <div className="space-y-4 mt-4 p-1">
@@ -1584,8 +1213,8 @@ export function InternalResourceForm({
)} )}
</div> </div>
{/* SSH Access tab (host mode only) */} {/* SSH Access tab */}
{!disableEnterpriseFeatures && mode === "host" && ( {!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4 p-1"> <div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8"> <div className="mb-8">

View File

@@ -405,11 +405,7 @@ export function LogDataTable<TData, TValue>({
onClick={() => onClick={() =>
!disabled && onExport() !disabled && onExport()
} }
disabled={ disabled={isExporting || disabled || isExportDisabled}
isExporting ||
disabled ||
isExportDisabled
}
> >
{isExporting ? ( {isExporting ? (
<Loader className="mr-2 size-4 animate-spin" /> <Loader className="mr-2 size-4 animate-spin" />

View File

@@ -352,9 +352,9 @@ export default function PendingSitesTable({
<Link <Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`} href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline">
{originalRow.exitNodeName} {originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>
); );

View File

@@ -144,9 +144,9 @@ export default function ShareLinksTable({
<Link <Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`} href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline">
{r.resourceName} {r.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>
); );

View File

@@ -362,9 +362,9 @@ export default function SitesTable({
<Link <Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`} href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline">
{originalRow.exitNodeName} {originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>
); );

View File

@@ -373,12 +373,12 @@ export default function UserDevicesTable({
<Link <Link
href={`/${r.orgId}/settings/access/users/${r.userId}`} href={`/${r.orgId}/settings/access/users/${r.userId}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline">
{getUserDisplayName({ {getUserDisplayName({
email: r.userEmail, email: r.userEmail,
username: r.username username: r.username
}) || r.userId} }) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" /> <ArrowUpRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>
) : ( ) : (

View File

@@ -1,117 +0,0 @@
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { Checkbox } from "./ui/checkbox";
import { useTranslations } from "next-intl";
import { useDebounce } from "use-debounce";
import type { Selectedsite } from "./site-selector";
export type MultiSitesSelectorProps = {
orgId: string;
selectedSites: Selectedsite[];
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
};
export function formatMultiSitesSelectorLabel(
selectedSites: Selectedsite[],
t: (key: string, values?: { count: number }) => string
): string {
if (selectedSites.length === 0) {
return t("selectSites");
}
if (selectedSites.length === 1) {
return selectedSites[0]!.name;
}
return t("multiSitesSelectorSitesCount", {
count: selectedSites.length
});
}
export function MultiSitesSelector({
orgId,
selectedSites,
onSelectionChange,
filterTypes
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
const { data: sites = [] } = useQuery(
orgQueries.sites({
orgId,
query: debouncedQuery,
perPage: 10
})
);
const sitesShown = useMemo(() => {
const base = filterTypes
? sites.filter((s) => filterTypes.includes(s.type))
: [...sites];
if (debouncedQuery.trim().length === 0 && selectedSites.length > 0) {
const selectedNotInBase = selectedSites.filter(
(sel) => !base.some((s) => s.siteId === sel.siteId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, sites, selectedSites, filterTypes]);
const selectedIds = useMemo(
() => new Set(selectedSites.map((s) => s.siteId)),
[selectedSites]
);
const toggleSite = (site: Selectedsite) => {
if (selectedIds.has(site.siteId)) {
onSelectionChange(
selectedSites.filter((s) => s.siteId !== site.siteId)
);
} else {
onSelectionChange([...selectedSites, site]);
}
};
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("siteSearch")}
value={siteSearchQuery}
onValueChange={(v) => setSiteSearchQuery(v)}
/>
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() => {
toggleSite(site);
}}
>
<Checkbox
className="pointer-events-none shrink-0"
checked={selectedIds.has(site.siteId)}
onCheckedChange={() => {}}
aria-hidden
tabIndex={-1}
/>
<span className="truncate">{site.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -43,8 +43,8 @@ const Checkbox = React.forwardRef<
className={cn(checkboxVariants({ variant }), className)} className={cn(checkboxVariants({ variant }), className)}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className="flex items-center justify-center"> <CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-4 w-4 text-white" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)); ));