mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-12 21:06:37 +00:00
Compare commits
25 Commits
1.17.0
...
private-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e239d1ad | ||
|
|
5f79e8ebbd | ||
|
|
1564c4bee7 | ||
|
|
0cf385b718 | ||
|
|
83ecf53776 | ||
|
|
5803da4893 | ||
|
|
fc4633db91 | ||
|
|
9e50569c31 | ||
|
|
a19f0acfb9 | ||
|
|
8a47d69d0d | ||
|
|
73482c2a05 | ||
|
|
79751c208d | ||
|
|
510931e7d6 | ||
|
|
584a8e7d1d | ||
|
|
a74378e1d3 | ||
|
|
c027c8958b | ||
|
|
a730f4da1d | ||
|
|
d73796b92e | ||
|
|
e4cbf088b4 | ||
|
|
333ccb8438 | ||
|
|
eb771ceda4 | ||
|
|
1efd2af44b | ||
|
|
466f137590 | ||
|
|
28ef5238c9 | ||
|
|
d948d2ec33 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @oschwartz10612 @miloschwartz
|
||||||
@@ -1817,6 +1817,11 @@
|
|||||||
"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.",
|
||||||
@@ -1832,6 +1837,7 @@
|
|||||||
"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",
|
||||||
@@ -1860,11 +1866,19 @@
|
|||||||
"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.",
|
||||||
@@ -2116,6 +2130,7 @@
|
|||||||
"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",
|
||||||
@@ -2422,6 +2437,7 @@
|
|||||||
"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",
|
||||||
@@ -2659,8 +2675,12 @@
|
|||||||
"editInternalResourceDialogAddUsers": "Add Users",
|
"editInternalResourceDialogAddUsers": "Add Users",
|
||||||
"editInternalResourceDialogAddClients": "Add Clients",
|
"editInternalResourceDialogAddClients": "Add Clients",
|
||||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||||
"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.",
|
"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.",
|
||||||
"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",
|
||||||
@@ -2699,6 +2719,8 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ 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") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysConnection: integer(
|
||||||
|
"settingsLogRetentionDaysConnection"
|
||||||
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.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)
|
||||||
@@ -101,7 +103,9 @@ 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").$type<"pending" | "approved">().default("approved")
|
status: varchar("status")
|
||||||
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = pgTable("resources", {
|
export const resources = pgTable("resources", {
|
||||||
@@ -230,8 +234,9 @@ 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(),
|
||||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
protocol: varchar("protocol"), // only for port mode
|
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||||
|
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
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
|
||||||
@@ -244,7 +249,12 @@ 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", {
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ 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") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysConnection: integer(
|
||||||
|
"settingsLogRetentionDaysConnection"
|
||||||
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.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)
|
||||||
@@ -258,8 +260,9 @@ 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(),
|
||||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
protocol: text("protocol"), // only for port mode
|
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||||
|
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
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
|
||||||
@@ -274,7 +277,12 @@ 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", {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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() {
|
||||||
@@ -39,6 +40,7 @@ async function startServers() {
|
|||||||
initTelemetryClient();
|
initTelemetryClient();
|
||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
|
initAcmeCertSync();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
3
server/lib/acmeCertSync.ts
Normal file
3
server/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function initAcmeCertSync(): void {
|
||||||
|
// stub
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
|
domains,
|
||||||
|
orgDomains,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
@@ -11,10 +13,97 @@ 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 } from "drizzle-orm";
|
import { eq, and, ne, inArray, or, isNotNull } 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;
|
||||||
@@ -76,20 +165,40 @@ 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: resourceData.mode,
|
mode: mappedMode.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(
|
||||||
@@ -100,7 +209,6 @@ 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)
|
||||||
@@ -207,12 +315,24 @@ 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 (resourceData.mode == "host") {
|
if (mappedMode.mode === "host" || mappedMode.mode === "http") {
|
||||||
// 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)
|
||||||
@@ -221,15 +341,21 @@ export async function updateClientResources(
|
|||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
mode: resourceData.mode,
|
mode: mappedMode.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();
|
||||||
|
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ function checkIfTargetChanged(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDomain(
|
export async function getDomain(
|
||||||
resourceId: number | undefined,
|
resourceId: number | undefined,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
orgId: string,
|
orgId: string,
|
||||||
|
|||||||
@@ -325,16 +325,18 @@ 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"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
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(),
|
||||||
// destinationPort: z.int().positive().optional(),
|
"destination-port": 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(
|
||||||
@@ -477,6 +479,39 @@ 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[]>();
|
||||||
|
|
||||||
|
|||||||
124
server/lib/ip.ts
124
server/lib/ip.ts
@@ -5,6 +5,7 @@ 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 "#private/lib/certificates";
|
||||||
|
|
||||||
interface IPRange {
|
interface IPRange {
|
||||||
start: bigint;
|
start: bigint;
|
||||||
@@ -477,9 +478,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.alias && sr.aliasAddress && sr.mode == "host")
|
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
alias: sr.alias,
|
alias: sr.alias || sr.fullDomain,
|
||||||
aliasAddress: sr.aliasAddress
|
aliasAddress: sr.aliasAddress
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -582,16 +583,26 @@ 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 function generateSubnetProxyTargetV2(
|
export type HTTPTarget = {
|
||||||
|
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;
|
||||||
}[]
|
}[]
|
||||||
): SubnetProxyTargetV2 | undefined {
|
): Promise<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.`
|
||||||
@@ -619,7 +630,7 @@ export function generateSubnetProxyTargetV2(
|
|||||||
destPrefix: destination,
|
destPrefix: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +642,7 @@ export 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") {
|
||||||
@@ -640,7 +651,68 @@ export 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 } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,33 +742,31 @@ export 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,
|
||||||
destPrefix: targetV2.destPrefix,
|
destPrefix: targetV2.destPrefix,
|
||||||
...(targetV2.disableIcmp !== undefined && {
|
...(targetV2.disableIcmp !== undefined && {
|
||||||
disableIcmp: targetV2.disableIcmp
|
disableIcmp: targetV2.disableIcmp
|
||||||
}),
|
}),
|
||||||
...(targetV2.rewriteTo !== undefined && {
|
...(targetV2.rewriteTo !== undefined && {
|
||||||
rewriteTo: targetV2.rewriteTo
|
rewriteTo: targetV2.rewriteTo
|
||||||
}),
|
}),
|
||||||
...(targetV2.portRange !== undefined && {
|
...(targetV2.portRange !== undefined && {
|
||||||
portRange: targetV2.portRange
|
portRange: targetV2.portRange
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (addedClients.length > 0) {
|
if (addedClients.length > 0) {
|
||||||
const targetToAdd = generateSubnetProxyTargetV2(
|
const targetToAdd = await generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
addedClients
|
addedClients
|
||||||
);
|
);
|
||||||
@@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (removedClients.length > 0) {
|
if (removedClients.length > 0) {
|
||||||
const targetToRemove = generateSubnetProxyTargetV2(
|
const targetToRemove = await generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
removedClients
|
removedClients
|
||||||
);
|
);
|
||||||
@@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const target = generateSubnetProxyTargetV2(resource, [
|
const target = await 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 = generateSubnetProxyTargetV2(resource, [
|
const target = await generateSubnetProxyTargetV2(resource, [
|
||||||
{
|
{
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
pubKey: client.pubKey,
|
pubKey: client.pubKey,
|
||||||
|
|||||||
277
server/private/lib/acmeCertSync.ts
Normal file
277
server/private/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { certificates, domains, db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encryptData, decryptData } from "@server/lib/encryption";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
|
interface AcmeCert {
|
||||||
|
domain: { main: string; sans?: string[] };
|
||||||
|
certificate: string;
|
||||||
|
key: string;
|
||||||
|
Store: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcmeJson {
|
||||||
|
[resolver: string]: {
|
||||||
|
Certificates: AcmeCert[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEncryptionKey(): Buffer {
|
||||||
|
const keyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||||
|
if (!keyHex) {
|
||||||
|
throw new Error("acmeCertSync: encryption key is not configured");
|
||||||
|
}
|
||||||
|
return Buffer.from(keyHex, "hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||||
|
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||||
|
const lookupDomain = certDomain.startsWith("*.")
|
||||||
|
? certDomain.slice(2)
|
||||||
|
: certDomain;
|
||||||
|
|
||||||
|
// 1. Exact baseDomain match (any domain type)
|
||||||
|
const exactMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.baseDomain, lookupDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (exactMatch.length > 0) {
|
||||||
|
return exactMatch[0].domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||||
|
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||||
|
// matches a wildcard domain with baseDomain "example.com".
|
||||||
|
const parts = lookupDomain.split(".");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const candidate = parts.slice(i).join(".");
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
const wildcardMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.baseDomain, candidate),
|
||||||
|
eq(domains.type, "wildcard")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (wildcardMatch.length > 0) {
|
||||||
|
return wildcardMatch[0].domainId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstCert(pemBundle: string): string | null {
|
||||||
|
const match = pemBundle.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(
|
||||||
|
acmeJsonPath: string,
|
||||||
|
resolver: string
|
||||||
|
): Promise<void> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acmeJson: AcmeJson;
|
||||||
|
try {
|
||||||
|
acmeJson = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptionKey = getEncryptionKey();
|
||||||
|
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
const domain = cert.domain?.main;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert with missing domain`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert.certificate || !cert.key) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
|
||||||
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cert already exists in DB
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decryptData(
|
||||||
|
existing[0].certFile,
|
||||||
|
encryptionKey
|
||||||
|
);
|
||||||
|
if (storedCertPem === certPem) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Decryption failure means we should proceed with the update
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cert expiry from the first cert in the PEM bundle
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
const firstCertPem = extractFirstCert(certPem);
|
||||||
|
if (firstCertPem) {
|
||||||
|
try {
|
||||||
|
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(x509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
const encryptedCert = encryptData(certPem, encryptionKey);
|
||||||
|
const encryptedKey = encryptData(keyPem, encryptionKey);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAcmeCertSync(): void {
|
||||||
|
const privateConfig = config.getRawPrivateConfig();
|
||||||
|
|
||||||
|
if (!privateConfig.flags?.enable_acme_cert_sync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acmeJsonPath =
|
||||||
|
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||||
|
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
|
||||||
|
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run immediately on init, then on the configured interval
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
@@ -95,10 +95,21 @@ 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(false)
|
||||||
})
|
})
|
||||||
.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(),
|
||||||
|
|||||||
@@ -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, Target, targets } from "@server/db";
|
import { orgs, resources, sites, siteResources, Target, targets } from "@server/db";
|
||||||
import {
|
import {
|
||||||
sanitize,
|
sanitize,
|
||||||
encodePath,
|
encodePath,
|
||||||
@@ -267,6 +267,34 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||||
|
const siteResourcesWithAliases = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
mode: siteResources.mode
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, siteResources.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
isNotNull(siteResources.alias),
|
||||||
|
eq(siteResources.mode, "http"),
|
||||||
|
eq(siteResources.ssl, true),
|
||||||
|
or(
|
||||||
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
|
and(
|
||||||
|
isNull(sites.exitNodeId),
|
||||||
|
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||||
|
eq(sites.type, "local"),
|
||||||
|
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
inArray(sites.type, siteTypes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
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
|
||||||
@@ -276,6 +304,12 @@ 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 siteResourcesWithAliases) {
|
||||||
|
if (sr.alias) {
|
||||||
|
domains.add(sr.alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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)}`);
|
||||||
@@ -867,6 +901,128 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
|
// matching resource exists yet.
|
||||||
|
if (siteResourcesWithAliases.length > 0) {
|
||||||
|
// Build a set of domains already covered by normal resources
|
||||||
|
const existingFullDomains = new Set<string>();
|
||||||
|
for (const resource of resourcesMap.values()) {
|
||||||
|
if (resource.fullDomain) {
|
||||||
|
existingFullDomains.add(resource.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sr of siteResourcesWithAliases) {
|
||||||
|
if (!sr.alias) continue;
|
||||||
|
|
||||||
|
// Skip if this alias is already handled by a resource router
|
||||||
|
if (existingFullDomains.has(sr.alias)) continue;
|
||||||
|
|
||||||
|
const alias = sr.alias;
|
||||||
|
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||||
|
const siteResourceServiceName = `${srKey}-service`;
|
||||||
|
const siteResourceRouterName = `${srKey}-router`;
|
||||||
|
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
||||||
|
|
||||||
|
const maintenancePort = config.getRawConfig().server.next_port;
|
||||||
|
const maintenanceHost =
|
||||||
|
config.getRawConfig().server.internal_hostname;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) {
|
||||||
|
config_output.http.routers = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.services) {
|
||||||
|
config_output.http.services = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service pointing at the internal maintenance/Next.js page
|
||||||
|
config_output.http.services[siteResourceServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
passHostHeader: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware that rewrites any path to /maintenance-screen
|
||||||
|
config_output.http.middlewares[
|
||||||
|
siteResourceRewriteMiddlewareName
|
||||||
|
] = {
|
||||||
|
replacePathRegex: {
|
||||||
|
regex: "^/(.*)",
|
||||||
|
replacement: "/private-maintenance-screen"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||||
|
config_output.http.routers[
|
||||||
|
`${siteResourceRouterName}-redirect`
|
||||||
|
] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
rule: `Host(\`${alias}\`)`,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine TLS / cert-resolver configuration
|
||||||
|
let tls: any = {};
|
||||||
|
if (
|
||||||
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
|
) {
|
||||||
|
const domainParts = alias.split(".");
|
||||||
|
const wildCard =
|
||||||
|
domainParts.length <= 2
|
||||||
|
? `*.${domainParts.join(".")}`
|
||||||
|
: `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: globalDefaultResolver,
|
||||||
|
...(globalDefaultPreferWildcard
|
||||||
|
? { domains: [{ main: wildCard }] }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// pangolin-dns: only add route if we already have a valid cert
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === alias
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for siteResource alias: ${alias}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS router — presence of this entry triggers cert generation
|
||||||
|
config_output.http.routers[siteResourceRouterName] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
|
],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
middlewares: [siteResourceRewriteMiddlewareName],
|
||||||
|
rule: `Host(\`${alias}\`)`,
|
||||||
|
priority: 100,
|
||||||
|
tls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (generateLoginPageRouters) {
|
if (generateLoginPageRouters) {
|
||||||
const exitNodeLoginPages = await db
|
const exitNodeLoginPages = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
166
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
166
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* 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 } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { inflate } from "zlib";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||||
|
|
||||||
|
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,
|
||||||
|
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)}`);
|
||||||
|
|
||||||
|
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 : "");
|
||||||
|
|
||||||
|
await logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 108,
|
||||||
|
resourceId: entry.resourceId,
|
||||||
|
orgId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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})`
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,3 +12,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./handleConnectionLogMessage";
|
export * from "./handleConnectionLogMessage";
|
||||||
|
export * from "./handleRequestLogMessage";
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ 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 } from "#private/routers/newt";
|
import { handleConnectionLogMessage, handleRequestLogMessage } 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") {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const resourceTarget = generateSubnetProxyTargetV2(
|
const resourceTarget = await generateSubnetProxyTargetV2(
|
||||||
resource,
|
resource,
|
||||||
resourceClients
|
resourceClients
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 client 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 site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
9
server/routers/newt/handleRequestLogMessage.ts
Normal file
9
server/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
|
||||||
|
export async function flushRequestLogToDb(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -9,4 +9,5 @@ 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";
|
||||||
|
|||||||
@@ -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 { eq, inArray } from "drizzle-orm";
|
import { 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 = 2;
|
const MAX_RETRIES = 5;
|
||||||
const BASE_DELAY_MS = 50;
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
@@ -36,6 +36,14 @@ 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 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +80,12 @@ 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) {
|
||||||
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
const pingsToFlush = new Map(pendingSitePings);
|
const pingsToFlush = new Map(pendingSitePings);
|
||||||
pendingSitePings.clear();
|
pendingSitePings.clear();
|
||||||
|
|
||||||
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
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 < sortedEntries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
const batch = entries.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
|
await db
|
||||||
const byTimestamp = new Map<number, number[]>();
|
.update(sites)
|
||||||
for (const [siteId, timestamp] of batch) {
|
.set({
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
online: true,
|
||||||
group.push(siteId);
|
lastPing: maxTimestamp
|
||||||
byTimestamp.set(timestamp, group);
|
})
|
||||||
}
|
.where(inArray(sites.siteId, siteIds));
|
||||||
|
|
||||||
if (byTimestamp.size === 1) {
|
|
||||||
const [timestamp, siteIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: true,
|
|
||||||
lastPing: timestamp
|
|
||||||
})
|
|
||||||
.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) {
|
||||||
@@ -144,6 +138,8 @@ 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) {
|
||||||
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
// ── Flush client pings ─────────────────────────────────────────────
|
// ── Flush client pings ─────────────────────────────────────────────
|
||||||
if (pingsToFlush.size > 0) {
|
if (pingsToFlush.size > 0) {
|
||||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
const entries = Array.from(pingsToFlush.entries());
|
||||||
([a], [b]) => a - b
|
|
||||||
);
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
const batch = entries.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[]>();
|
await db
|
||||||
for (const [clientId, timestamp] of batch) {
|
.update(clients)
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
.set({
|
||||||
group.push(clientId);
|
lastPing: maxTimestamp,
|
||||||
byTimestamp.set(timestamp, group);
|
online: true,
|
||||||
}
|
archived: false
|
||||||
|
})
|
||||||
if (byTimestamp.size === 1) {
|
.where(inArray(clients.clientId, clientIds));
|
||||||
const [timestamp, clientIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
lastPing: timestamp,
|
|
||||||
online: true,
|
|
||||||
archived: false
|
|
||||||
})
|
|
||||||
.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(
|
||||||
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
* (connection timeouts, unexpected disconnects).
|
* (deadlocks, 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>,
|
||||||
@@ -277,7 +252,8 @@ 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;
|
||||||
@@ -288,14 +264,14 @@ async function withRetry<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect transient connection errors that are safe to retry.
|
* Detect transient 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 || "";
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
// Connection timeout / terminated
|
// Connection timeout / terminated
|
||||||
if (
|
if (
|
||||||
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL deadlock
|
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
if (
|
if (
|
||||||
code === "ECONNRESET" ||
|
code === "ECONNRESET" ||
|
||||||
code === "ECONNREFUSED" ||
|
code === "ECONNREFUSED" ||
|
||||||
@@ -337,12 +318,26 @@ 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);
|
||||||
|
|
||||||
@@ -364,7 +359,22 @@ 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) {
|
||||||
@@ -379,4 +389,4 @@ export async function stopPingAccumulator(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export function getPendingPingCount(): number {
|
export function getPendingPingCount(): number {
|
||||||
return pendingSitePings.size + pendingClientPings.size;
|
return pendingSitePings.size + pendingClientPings.size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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";
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export async function getUserResources(
|
|||||||
name: string;
|
name: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
protocol: string | null;
|
scheme: 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,
|
||||||
protocol: siteResources.protocol,
|
scheme: siteResources.scheme,
|
||||||
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.protocol,
|
protocol: siteResource.scheme,
|
||||||
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";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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()
|
||||||
@@ -36,11 +37,12 @@ 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", "port"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
siteId: z.int(),
|
siteId: z.int(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
scheme: z.enum(["http", "https"]).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
|
||||||
@@ -57,20 +59,24 @@ 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") {
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
if (data.mode == "host") {
|
||||||
const isValidIP = z
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
const isValidIP = z
|
||||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
.safeParse(data.destination).success;
|
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
|
.safeParse(data.destination).success;
|
||||||
|
|
||||||
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)
|
||||||
@@ -105,6 +111,21 @@ 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>;
|
||||||
@@ -161,11 +182,12 @@ export async function createSiteResource(
|
|||||||
name,
|
name,
|
||||||
siteId,
|
siteId,
|
||||||
mode,
|
mode,
|
||||||
// protocol,
|
scheme,
|
||||||
// proxyPort,
|
// proxyPort,
|
||||||
// destinationPort,
|
destinationPort,
|
||||||
destination,
|
destination,
|
||||||
enabled,
|
enabled,
|
||||||
|
ssl,
|
||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
@@ -174,7 +196,9 @@ export async function createSiteResource(
|
|||||||
udpPortRangeString,
|
udpPortRangeString,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode
|
authDaemonMode,
|
||||||
|
domainId,
|
||||||
|
subdomain
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
@@ -226,29 +250,50 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
if (domainId && alias) {
|
||||||
// if (mode === "port" && protocol && proxyPort) {
|
// throw an error because we can only have one or the other
|
||||||
// const [existingResource] = await db
|
return next(
|
||||||
// .select()
|
createHttpError(
|
||||||
// .from(siteResources)
|
HttpCode.BAD_REQUEST,
|
||||||
// .where(
|
"Alias and domain cannot both be set. Please choose one or the other."
|
||||||
// and(
|
)
|
||||||
// eq(siteResources.siteId, siteId),
|
);
|
||||||
// eq(siteResources.orgId, orgId),
|
}
|
||||||
// eq(siteResources.protocol, protocol),
|
|
||||||
// eq(siteResources.proxyPort, proxyPort)
|
let fullDomain: string | null = null;
|
||||||
// )
|
let finalSubdomain: string | null = null;
|
||||||
// )
|
if (domainId) {
|
||||||
// .limit(1);
|
// Validate domain and construct full domain
|
||||||
// if (existingResource && existingResource.siteResourceId) {
|
const domainResult = await validateAndConstructDomain(
|
||||||
// return next(
|
domainId,
|
||||||
// createHttpError(
|
orgId,
|
||||||
// HttpCode.CONFLICT,
|
subdomain
|
||||||
// "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) {
|
||||||
@@ -280,8 +325,7 @@ 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") {
|
if (mode === "host" || mode === "http") {
|
||||||
// we can only have an alias on a host
|
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,14 +337,20 @@ export async function createSiteResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode: mode as "host" | "cidr",
|
mode,
|
||||||
|
ssl,
|
||||||
destination,
|
destination,
|
||||||
|
scheme,
|
||||||
|
destinationPort,
|
||||||
enabled,
|
enabled,
|
||||||
alias,
|
alias: alias ? alias.trim() : null,
|
||||||
aliasAddress,
|
aliasAddress,
|
||||||
tcpPortRangeString,
|
tcpPortRangeString,
|
||||||
udpPortRangeString,
|
udpPortRangeString,
|
||||||
disableIcmp
|
disableIcmp,
|
||||||
|
domainId,
|
||||||
|
subdomain: finalSubdomain,
|
||||||
|
fullDomain
|
||||||
};
|
};
|
||||||
if (isLicensedSshPam) {
|
if (isLicensedSshPam) {
|
||||||
if (authDaemonPort !== undefined)
|
if (authDaemonPort !== undefined)
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
mode: z
|
mode: z
|
||||||
.enum(["host", "cidr"])
|
.enum(["host", "cidr", "http"])
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["host", "cidr"],
|
enum: ["host", "cidr", "http"],
|
||||||
description: "Filter site resources by mode"
|
description: "Filter site resources by mode"
|
||||||
}),
|
}),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
@@ -88,7 +88,8 @@ function querySiteResourcesBase() {
|
|||||||
niceId: siteResources.niceId,
|
niceId: siteResources.niceId,
|
||||||
name: siteResources.name,
|
name: siteResources.name,
|
||||||
mode: siteResources.mode,
|
mode: siteResources.mode,
|
||||||
protocol: siteResources.protocol,
|
ssl: siteResources.ssl,
|
||||||
|
scheme: siteResources.scheme,
|
||||||
proxyPort: siteResources.proxyPort,
|
proxyPort: siteResources.proxyPort,
|
||||||
destinationPort: siteResources.destinationPort,
|
destinationPort: siteResources.destinationPort,
|
||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
@@ -100,6 +101,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
|
||||||
@@ -193,7 +197,9 @@ 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().where(and(...conditions)).as("filtered_site_resources")
|
querySiteResourcesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_site_resources")
|
||||||
);
|
);
|
||||||
|
|
||||||
const [siteResourcesList, totalCount] = await Promise.all([
|
const [siteResourcesList, totalCount] = await Promise.all([
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
@@ -51,10 +52,11 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr"]).optional(),
|
mode: z.enum(["host", "cidr", "http"]).optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
ssl: z.boolean().optional(),
|
||||||
|
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
|
||||||
@@ -71,7 +73,9 @@ 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(
|
||||||
@@ -118,6 +122,23 @@ 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>;
|
||||||
@@ -175,8 +196,11 @@ 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,
|
||||||
@@ -185,7 +209,9 @@ 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
|
||||||
@@ -275,6 +301,45 @@ 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
|
||||||
@@ -346,12 +411,18 @@ export async function updateSiteResource(
|
|||||||
siteId,
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
|
scheme,
|
||||||
|
ssl,
|
||||||
destination,
|
destination,
|
||||||
|
destinationPort,
|
||||||
enabled,
|
enabled,
|
||||||
alias: alias && alias.trim() ? alias : null,
|
alias: alias ? alias.trim() : null,
|
||||||
tcpPortRangeString,
|
tcpPortRangeString,
|
||||||
udpPortRangeString,
|
udpPortRangeString,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
|
domainId,
|
||||||
|
subdomain: finalSubdomain,
|
||||||
|
fullDomain,
|
||||||
...sshPamSet
|
...sshPamSet
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -448,13 +519,20 @@ 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() ? alias : null,
|
alias: alias ? alias.trim() : null,
|
||||||
tcpPortRangeString: tcpPortRangeString,
|
tcpPortRangeString: tcpPortRangeString,
|
||||||
udpPortRangeString: udpPortRangeString,
|
udpPortRangeString: udpPortRangeString,
|
||||||
disableIcmp: disableIcmp,
|
disableIcmp: disableIcmp,
|
||||||
|
domainId,
|
||||||
|
subdomain: finalSubdomain,
|
||||||
|
fullDomain,
|
||||||
...sshPamSet
|
...sshPamSet
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -589,9 +667,14 @@ 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 !==
|
||||||
@@ -603,7 +686,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) {
|
if (destinationChanged || aliasChanged || portRangesChanged || destinationPortChanged) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
@@ -617,12 +700,12 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only update targets on newt if destination changed
|
// Only update targets on newt if destination changed
|
||||||
if (destinationChanged || portRangesChanged) {
|
if (destinationChanged || portRangesChanged || destinationPortChanged) {
|
||||||
const oldTarget = generateSubnetProxyTargetV2(
|
const oldTarget = await generateSubnetProxyTargetV2(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
);
|
);
|
||||||
const newTarget = generateSubnetProxyTargetV2(
|
const newTarget = await generateSubnetProxyTargetV2(
|
||||||
updatedSiteResource,
|
updatedSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -235,7 +235,9 @@ 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")
|
||||||
VALUES (${row.inviteId}, ${row.roleId})
|
SELECT ${row.inviteId}, ${row.roleId}
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -258,7 +260,10 @@ 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")
|
||||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';`
|
`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);`
|
||||||
).run();
|
).run();
|
||||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -246,12 +246,15 @@ 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") VALUES (?, ?)`
|
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||||
|
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);
|
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,12 +268,16 @@ 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") VALUES (?, ?, ?)`
|
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||||
|
SELECT ?, ?, ?
|
||||||
|
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
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);
|
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -471,11 +471,7 @@ export default function GeneralPage() {
|
|||||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="outline" size="sm">
|
||||||
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>
|
||||||
|
|||||||
@@ -451,11 +451,7 @@ 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
|
<Button variant="outline" size="sm">
|
||||||
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>
|
||||||
@@ -497,11 +493,7 @@ 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
|
<Button variant="outline" size="sm">
|
||||||
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" />
|
||||||
@@ -675,9 +667,7 @@ export default function ConnectionLogsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<strong>Ended At:</strong>{" "}
|
<strong>Ended At:</strong>{" "}
|
||||||
{row.endedAt
|
{row.endedAt
|
||||||
? new Date(
|
? new Date(row.endedAt * 1000).toLocaleString()
|
||||||
row.endedAt * 1000
|
|
||||||
).toLocaleString()
|
|
||||||
: "Active"}
|
: "Active"}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ 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
|
||||||
@@ -377,6 +378,7 @@ 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"),
|
||||||
@@ -513,11 +515,7 @@ export default function GeneralPage() {
|
|||||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="outline" size="sm">
|
||||||
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>
|
||||||
@@ -634,6 +632,7 @@ 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") },
|
||||||
|
|||||||
@@ -56,18 +56,38 @@ 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
|
||||||
|
}
|
||||||
|
],
|
||||||
siteName: siteResource.siteName,
|
siteName: siteResource.siteName,
|
||||||
siteAddress: siteResource.siteAddress || null,
|
siteAddress: siteResource.siteAddress || null,
|
||||||
mode: siteResource.mode || ("port" as any),
|
mode: normalizedMode,
|
||||||
|
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,
|
||||||
// destinationPort: siteResource.destinationPort,
|
httpHttpsPort: siteResource.destinationPort ?? null,
|
||||||
alias: siteResource.alias || null,
|
alias: siteResource.alias || null,
|
||||||
aliasAddress: siteResource.aliasAddress || null,
|
aliasAddress: siteResource.aliasAddress || null,
|
||||||
siteNiceId: siteResource.siteNiceId,
|
siteNiceId: siteResource.siteNiceId,
|
||||||
@@ -76,7 +96,10 @@ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
32
src/app/private-maintenance-screen/page.tsx
Normal file
32
src/app/private-maintenance-screen/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,21 +38,35 @@ 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 {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger
|
||||||
|
} from "@app/components/ui/popover";
|
||||||
|
|
||||||
|
export type InternalResourceSiteRow = {
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteNiceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
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";
|
mode: "host" | "cidr" | "http";
|
||||||
|
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;
|
||||||
// destinationPort: number | null;
|
httpHttpsPort: number | null;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
@@ -61,8 +75,140 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SITE_LINKS = 3;
|
||||||
|
|
||||||
|
function ClientResourceSiteLinks({
|
||||||
|
orgId,
|
||||||
|
sites
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
sites: InternalResourceSiteRow[];
|
||||||
|
}) {
|
||||||
|
if (sites.length === 0) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
const visible = sites.slice(0, MAX_SITE_LINKS);
|
||||||
|
const overflow = sites.slice(MAX_SITE_LINKS);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{visible.map((site) => (
|
||||||
|
<Link
|
||||||
|
key={site.siteId}
|
||||||
|
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-1"
|
||||||
|
>
|
||||||
|
<span className="max-w-[10rem] truncate">
|
||||||
|
{site.siteName}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight className="h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{overflow.length > 0 ? (
|
||||||
|
<OverflowSitesPopover orgId={orgId} sites={overflow} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverflowSitesPopover({
|
||||||
|
orgId,
|
||||||
|
sites
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
sites: InternalResourceSiteRow[];
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 px-2 font-normal"
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
+{sites.length}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
side="top"
|
||||||
|
className="w-auto max-w-xs p-2"
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-col gap-1.5 text-sm">
|
||||||
|
{sites.map((site) => (
|
||||||
|
<li key={site.siteId}>
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-1"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{site.siteName}
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight className="ml-auto h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -185,20 +331,18 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "siteName",
|
id: "sites",
|
||||||
friendlyName: t("site"),
|
accessorFn: (row) =>
|
||||||
header: () => <span className="p-3">{t("site")}</span>,
|
row.sites.map((s) => s.siteName).join(", ") || row.siteName,
|
||||||
|
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 (
|
||||||
<Link
|
<ClientResourceSiteLinks
|
||||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
orgId={resourceRow.orgId}
|
||||||
>
|
sites={resourceRow.sites}
|
||||||
<Button variant="outline">
|
/>
|
||||||
{resourceRow.siteName}
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -215,6 +359,10 @@ 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}
|
||||||
@@ -227,10 +375,14 @@ export default function ClientResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
const modeLabels: Record<"host" | "cidr" | "port", string> = {
|
const modeLabels: Record<
|
||||||
|
"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>;
|
||||||
}
|
}
|
||||||
@@ -243,11 +395,12 @@ 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={resourceRow.destination}
|
text={display}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
displayText={resourceRow.destination}
|
displayText={display}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,15 +413,26 @@ export default function ClientResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return resourceRow.mode === "host" && resourceRow.alias ? (
|
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||||
<CopyToClipboard
|
return (
|
||||||
text={resourceRow.alias}
|
<CopyToClipboard
|
||||||
isLink={false}
|
text={resourceRow.alias}
|
||||||
displayText={resourceRow.alias}
|
isLink={false}
|
||||||
/>
|
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>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export default function CreateInternalResourceDialog({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (data.mode === "host" && isHostname(data.destination)) {
|
if (
|
||||||
|
(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;
|
||||||
@@ -65,25 +68,56 @@ export default function CreateInternalResourceDialog({
|
|||||||
`/org/${orgId}/site-resource`,
|
`/org/${orgId}/site-resource`,
|
||||||
{
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteId: data.siteId,
|
siteId: data.siteIds[0],
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
...(data.mode === "http" && {
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
scheme: data.scheme,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
ssl: data.ssl ?? false,
|
||||||
disableIcmp: data.disableIcmp ?? false,
|
destinationPort: data.httpHttpsPort ?? undefined,
|
||||||
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
|
domainId: data.httpConfigDomainId
|
||||||
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
|
? data.httpConfigDomainId
|
||||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
: 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,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false
|
||||||
|
}),
|
||||||
|
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 ? data.clients.map((c) => parseInt(c.id)) : []
|
clientIds: data.clients
|
||||||
|
? data.clients.map((c) => parseInt(c.id))
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
description: t(
|
||||||
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -93,7 +127,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
title: t("createInternalResourceDialogError"),
|
title: t("createInternalResourceDialogError"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
error,
|
error,
|
||||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
t(
|
||||||
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
@@ -106,9 +142,13 @@ 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>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("createInternalResourceDialogCreateClientResource")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
{t(
|
||||||
|
"createInternalResourceDialogCreateClientResourceDescription"
|
||||||
|
)}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
@@ -123,7 +163,11 @@ export default function CreateInternalResourceDialog({
|
|||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
{t("createInternalResourceDialogCancel")}
|
{t("createInternalResourceDialogCancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
|
|||||||
@@ -163,15 +163,18 @@ 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:
|
subdomain: sub,
|
||||||
firstOrExistingDomain.type !== "cname"
|
fullDomain: sub ? `${sub}.${base}` : base,
|
||||||
? defaultSubdomain || undefined
|
baseDomain: base
|
||||||
: undefined,
|
|
||||||
fullDomain: firstOrExistingDomain.baseDomain,
|
|
||||||
baseDomain: firstOrExistingDomain.baseDomain
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,9 +512,11 @@ export default function DomainPicker({
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedBaseDomain.domain}
|
{selectedBaseDomain.domain}
|
||||||
</span>
|
</span>
|
||||||
{selectedBaseDomain.verified && (
|
{selectedBaseDomain.verified &&
|
||||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
selectedBaseDomain.domainType !==
|
||||||
)}
|
"wildcard" && (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t("domainPickerSelectBaseDomain")
|
t("domainPickerSelectBaseDomain")
|
||||||
@@ -574,14 +579,23 @@ export default function DomainPicker({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{orgDomain.type.toUpperCase()}{" "}
|
{orgDomain.type ===
|
||||||
•{" "}
|
"wildcard"
|
||||||
{orgDomain.verified
|
|
||||||
? t(
|
? t(
|
||||||
"domainPickerVerified"
|
"domainPickerManual"
|
||||||
)
|
)
|
||||||
: t(
|
: (
|
||||||
"domainPickerUnverified"
|
<>
|
||||||
|
{orgDomain.type.toUpperCase()}{" "}
|
||||||
|
•{" "}
|
||||||
|
{orgDomain.verified
|
||||||
|
? t(
|
||||||
|
"domainPickerVerified"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"domainPickerUnverified"
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function EditInternalResourceDialog({
|
|||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (data.mode === "host" && isHostname(data.destination)) {
|
if (
|
||||||
|
(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,24 +70,39 @@ 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.siteId,
|
siteId: data.siteIds[0],
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
alias:
|
...(data.mode === "http" && {
|
||||||
data.alias &&
|
scheme: data.scheme,
|
||||||
typeof data.alias === "string" &&
|
ssl: data.ssl ?? false,
|
||||||
data.alias.trim()
|
destinationPort: data.httpHttpsPort ?? null,
|
||||||
? data.alias
|
domainId: data.httpConfigDomainId
|
||||||
: null,
|
? data.httpConfigDomainId
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
: undefined,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
subdomain: data.httpConfigSubdomain
|
||||||
disableIcmp: data.disableIcmp ?? false,
|
? data.httpConfigSubdomain
|
||||||
...(data.authDaemonMode != null && {
|
: undefined
|
||||||
authDaemonMode: data.authDaemonMode
|
|
||||||
}),
|
}),
|
||||||
...(data.authDaemonMode === "remote" && {
|
...(data.mode === "host" && {
|
||||||
authDaemonPort: data.authDaemonPort || null
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: null,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" && {
|
||||||
|
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),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -405,7 +405,11 @@ export function LogDataTable<TData, TValue>({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
!disabled && onExport()
|
!disabled && onExport()
|
||||||
}
|
}
|
||||||
disabled={isExporting || disabled || isExportDisabled}
|
disabled={
|
||||||
|
isExporting ||
|
||||||
|
disabled ||
|
||||||
|
isExportDisabled
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<Loader className="mr-2 size-4 animate-spin" />
|
<Loader className="mr-2 size-4 animate-spin" />
|
||||||
|
|||||||
@@ -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">
|
<Button variant="outline" size="sm">
|
||||||
{originalRow.exitNodeName}
|
{originalRow.exitNodeName}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
<Button variant="outline" size="sm">
|
||||||
{r.resourceName}
|
{r.resourceName}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
<Button variant="outline" size="sm">
|
||||||
{originalRow.exitNodeName}
|
{originalRow.exitNodeName}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
<Button variant="outline" size="sm">
|
||||||
{getUserDisplayName({
|
{getUserDisplayName({
|
||||||
email: r.userEmail,
|
email: r.userEmail,
|
||||||
username: r.username
|
username: r.username
|
||||||
}) || r.userId}
|
}) || r.userId}
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
117
src/components/multi-site-selector.tsx
Normal file
117
src/components/multi-site-selector.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 text-current">
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4 text-white" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
));
|
));
|
||||||
|
|||||||
Reference in New Issue
Block a user