mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-14 13:56:36 +00:00
Compare commits
55 Commits
1.17.1-s.1
...
private-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ae5eecb6 | ||
|
|
646e440dec | ||
|
|
03d95874e6 | ||
|
|
1b9a395432 | ||
|
|
3996e14e70 | ||
|
|
7a40084bf4 | ||
|
|
30fd48a14a | ||
|
|
5d51af4330 | ||
|
|
173a81ead8 | ||
|
|
676eacc9cf | ||
|
|
561a9ab379 | ||
|
|
71497a7887 | ||
|
|
aa41a63430 | ||
|
|
0db55daff6 | ||
|
|
9b271950d2 | ||
|
|
89b6b1fb56 | ||
|
|
789b991c56 | ||
|
|
0cbcc0c29c | ||
|
|
b5e239d1ad | ||
|
|
5f79e8ebbd | ||
|
|
1564c4bee7 | ||
|
|
0cf385b718 | ||
|
|
83ecf53776 | ||
|
|
5803da4893 | ||
|
|
fc4633db91 | ||
|
|
9e50569c31 | ||
|
|
a19f0acfb9 | ||
|
|
8a47d69d0d | ||
|
|
73482c2a05 | ||
|
|
79751c208d | ||
|
|
510931e7d6 | ||
|
|
584a8e7d1d | ||
|
|
a74378e1d3 | ||
|
|
c027c8958b | ||
|
|
a730f4da1d | ||
|
|
d73796b92e | ||
|
|
96b9123306 | ||
|
|
e4cbf088b4 | ||
|
|
333ccb8438 | ||
|
|
eb771ceda4 | ||
|
|
1efd2af44b | ||
|
|
02033f611f | ||
|
|
1366901e24 | ||
|
|
c4f48f5748 | ||
|
|
c48bc71443 | ||
|
|
d85496453f | ||
|
|
21b91374a3 | ||
|
|
a1ce7f54a0 | ||
|
|
87524fe8ae | ||
|
|
2093bb5357 | ||
|
|
6f2e37948c | ||
|
|
b7421e47cc | ||
|
|
7cbe3d42a1 | ||
|
|
d8b511b198 | ||
|
|
102a235407 |
@@ -1821,6 +1821,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.",
|
||||||
@@ -1836,6 +1841,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",
|
||||||
@@ -1864,11 +1870,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.",
|
||||||
@@ -2428,6 +2442,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",
|
||||||
@@ -2665,8 +2680,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",
|
||||||
@@ -2705,6 +2724,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", {
|
||||||
@@ -222,16 +226,23 @@ export const exitNodes = pgTable("exitNodes", {
|
|||||||
export const siteResources = pgTable("siteResources", {
|
export const siteResources = pgTable("siteResources", {
|
||||||
// this is for the clients
|
// this is for the clients
|
||||||
siteResourceId: serial("siteResourceId").primaryKey(),
|
siteResourceId: serial("siteResourceId").primaryKey(),
|
||||||
siteId: integer("siteId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
networkId: integer("networkId").references(() => networks.networkId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
|
defaultNetworkId: integer("defaultNetworkId").references(
|
||||||
|
() => networks.networkId,
|
||||||
|
{
|
||||||
|
onDelete: "restrict"
|
||||||
|
}
|
||||||
|
),
|
||||||
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 +255,38 @@ 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 networks = pgTable("networks", {
|
||||||
|
networkId: serial("networkId").primaryKey(),
|
||||||
|
niceId: text("niceId"),
|
||||||
|
name: text("name"),
|
||||||
|
scope: varchar("scope")
|
||||||
|
.$type<"global" | "resource">()
|
||||||
|
.notNull()
|
||||||
|
.default("global"),
|
||||||
|
orgId: varchar("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteNetworks = pgTable("siteNetworks", {
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
networkId: integer("networkId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||||
@@ -994,6 +1036,7 @@ export const requestAuditLog = pgTable(
|
|||||||
actor: text("actor"),
|
actor: text("actor"),
|
||||||
actorId: text("actorId"),
|
actorId: text("actorId"),
|
||||||
resourceId: integer("resourceId"),
|
resourceId: integer("resourceId"),
|
||||||
|
siteResourceId: integer("siteResourceId"),
|
||||||
ip: text("ip"),
|
ip: text("ip"),
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
userAgent: text("userAgent"),
|
userAgent: text("userAgent"),
|
||||||
@@ -1106,3 +1149,4 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
|||||||
export type RoundTripMessageTracker = InferSelectModel<
|
export type RoundTripMessageTracker = InferSelectModel<
|
||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -92,6 +94,9 @@ export const sites = sqliteTable("sites", {
|
|||||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
|
networkId: integer("networkId").references(() => networks.networkId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
subnet: text("subnet"),
|
subnet: text("subnet"),
|
||||||
@@ -250,16 +255,21 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
siteResourceId: integer("siteResourceId").primaryKey({
|
siteResourceId: integer("siteResourceId").primaryKey({
|
||||||
autoIncrement: true
|
autoIncrement: true
|
||||||
}),
|
}),
|
||||||
siteId: integer("siteId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
networkId: integer("networkId").references(() => networks.networkId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
|
defaultNetworkId: integer("defaultNetworkId").references(
|
||||||
|
() => networks.networkId,
|
||||||
|
{ onDelete: "restrict" }
|
||||||
|
),
|
||||||
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 +284,36 @@ 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 networks = sqliteTable("networks", {
|
||||||
|
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||||
|
niceId: text("niceId"),
|
||||||
|
name: text("name"),
|
||||||
|
scope: text("scope")
|
||||||
|
.$type<"global" | "resource">()
|
||||||
|
.notNull()
|
||||||
|
.default("global"),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteNetworks = sqliteTable("siteNetworks", {
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
networkId: integer("networkId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||||
@@ -1096,6 +1135,7 @@ export const requestAuditLog = sqliteTable(
|
|||||||
actor: text("actor"),
|
actor: text("actor"),
|
||||||
actorId: text("actorId"),
|
actorId: text("actorId"),
|
||||||
resourceId: integer("resourceId"),
|
resourceId: integer("resourceId"),
|
||||||
|
siteResourceId: integer("siteResourceId"),
|
||||||
ip: text("ip"),
|
ip: text("ip"),
|
||||||
location: text("location"),
|
location: text("location"),
|
||||||
userAgent: text("userAgent"),
|
userAgent: text("userAgent"),
|
||||||
@@ -1195,6 +1235,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
|
|||||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||||
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export enum TierFeature {
|
|||||||
FullRbac = "fullRbac",
|
FullRbac = "fullRbac",
|
||||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||||
|
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,5 +59,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ export async function applyBlueprint({
|
|||||||
for (const result of clientResourcesResults) {
|
for (const result of clientResourcesResults) {
|
||||||
if (
|
if (
|
||||||
result.oldSiteResource &&
|
result.oldSiteResource &&
|
||||||
result.oldSiteResource.siteId !=
|
JSON.stringify(result.newSites?.sort()) !==
|
||||||
result.newSiteResource.siteId
|
JSON.stringify(result.oldSites?.sort())
|
||||||
) {
|
) {
|
||||||
// query existing associations
|
// query existing associations
|
||||||
const existingRoleIds = await trx
|
const existingRoleIds = await trx
|
||||||
@@ -222,13 +222,15 @@ export async function applyBlueprint({
|
|||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [newSite] = await trx
|
let good = true;
|
||||||
|
for (const newSite of result.newSites) {
|
||||||
|
const [site] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sites.siteId, result.newSiteResource.siteId),
|
eq(sites.siteId, newSite.siteId),
|
||||||
eq(sites.orgId, orgId),
|
eq(sites.orgId, orgId),
|
||||||
eq(sites.type, "newt"),
|
eq(sites.type, "newt"),
|
||||||
isNotNull(sites.pubKey)
|
isNotNull(sites.pubKey)
|
||||||
@@ -236,24 +238,30 @@ export async function applyBlueprint({
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!newSite) {
|
if (!site) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||||
);
|
);
|
||||||
continue;
|
good = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
|
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!good) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await handleMessagingForUpdatedSiteResource(
|
await handleMessagingForUpdatedSiteResource(
|
||||||
result.oldSiteResource,
|
result.oldSiteResource,
|
||||||
result.newSiteResource,
|
result.newSiteResource,
|
||||||
{
|
result.newSites.map((site) => ({
|
||||||
siteId: newSite.sites.siteId,
|
siteId: site.siteId,
|
||||||
orgId: newSite.sites.orgId
|
orgId: result.newSiteResource.orgId
|
||||||
},
|
})),
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,104 @@
|
|||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
|
domains,
|
||||||
|
orgDomains,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
|
Site,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
Transaction,
|
Transaction,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
users,
|
users,
|
||||||
userSiteResources
|
userSiteResources,
|
||||||
|
networks
|
||||||
} 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientResourcesResults = {
|
export type ClientResourcesResults = {
|
||||||
newSiteResource: SiteResource;
|
newSiteResource: SiteResource;
|
||||||
oldSiteResource?: SiteResource;
|
oldSiteResource?: SiteResource;
|
||||||
|
newSites: { siteId: number }[];
|
||||||
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateClientResources(
|
export async function updateClientResources(
|
||||||
@@ -43,12 +123,21 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
const existingSiteIds = existingResource?.networkId
|
||||||
|
? await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(eq(siteNetworks.networkId, existingResource.networkId))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let allSites: { siteId: number }[] = [];
|
||||||
|
if (resourceData.site) {
|
||||||
|
let siteSingle;
|
||||||
const resourceSiteId = resourceData.site;
|
const resourceSiteId = resourceData.site;
|
||||||
let site;
|
|
||||||
|
|
||||||
if (resourceSiteId) {
|
if (resourceSiteId) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[site] = await trx
|
[siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
@@ -60,36 +149,78 @@ export async function updateClientResources(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
} else if (siteId) {
|
} else if (siteId) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
[site] = await trx
|
[siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Target site is required`);
|
throw new Error(`Target site is required`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site) {
|
if (!siteSingle) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
allSites.push(siteSingle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.sites) {
|
||||||
|
for (const siteNiceId of resourceData.sites) {
|
||||||
|
const [site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.niceId, siteNiceId),
|
||||||
|
eq(sites.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (!site) {
|
||||||
|
throw new Error(
|
||||||
|
`Site not found: ${siteId} in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
allSites.push(site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
|
let domainInfo:
|
||||||
|
| { subdomain: string | null; domainId: string }
|
||||||
|
| undefined;
|
||||||
|
if (resourceData["full-domain"] && resourceData.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,
|
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
|
ssl: resourceData.ssl,
|
||||||
|
scheme: resourceData.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 +231,21 @@ export async function updateClientResources(
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const siteResourceId = existingResource.siteResourceId;
|
const siteResourceId = existingResource.siteResourceId;
|
||||||
const orgId = existingResource.orgId;
|
|
||||||
|
if (updatedResource.networkId) {
|
||||||
|
await trx
|
||||||
|
.delete(siteNetworks)
|
||||||
|
.where(
|
||||||
|
eq(siteNetworks.networkId, updatedResource.networkId)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const site of allSites) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: site.siteId,
|
||||||
|
networkId: updatedResource.networkId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSiteResources)
|
.delete(clientSiteResources)
|
||||||
@@ -204,37 +349,72 @@ export async function updateClientResources(
|
|||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
newSiteResource: updatedResource,
|
newSiteResource: updatedResource,
|
||||||
oldSiteResource: existingResource
|
oldSiteResource: existingResource,
|
||||||
|
newSites: allSites,
|
||||||
|
oldSites: existingSiteIds
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
if (resourceData.mode == "host") {
|
if (resourceData.mode === "host" || resourceData.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"] && resourceData.mode === "http") {
|
||||||
|
domainInfo = await getDomainForSiteResource(
|
||||||
|
undefined,
|
||||||
|
resourceData["full-domain"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [network] = await trx
|
||||||
|
.insert(networks)
|
||||||
|
.values({
|
||||||
|
scope: "resource",
|
||||||
|
orgId: orgId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
// Create new resource
|
// Create new resource
|
||||||
const [newResource] = await trx
|
const [newResource] = await trx
|
||||||
.insert(siteResources)
|
.insert(siteResources)
|
||||||
.values({
|
.values({
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
siteId: site.siteId,
|
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
|
networkId: network.networkId,
|
||||||
|
defaultNetworkId: network.networkId,
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
|
ssl: resourceData.ssl,
|
||||||
|
scheme: resourceData.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();
|
||||||
|
|
||||||
const siteResourceId = newResource.siteResourceId;
|
const siteResourceId = newResource.siteResourceId;
|
||||||
|
|
||||||
|
for (const site of allSites) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: site.siteId,
|
||||||
|
networkId: network.networkId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -324,7 +504,11 @@ export async function updateClientResources(
|
|||||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
results.push({ newSiteResource: newResource });
|
results.push({
|
||||||
|
newSiteResource: newResource,
|
||||||
|
newSites: allSites,
|
||||||
|
oldSites: existingSiteIds
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export const ResourceSchema = z
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
"full-domain": z.string().optional(),
|
"full-domain": z.string().optional(),
|
||||||
"proxy-port": z.int().min(1).max(65535).optional(),
|
"proxy-port": z.int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -325,16 +326,20 @@ 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(), // DEPRECATED IN FAVOR OF sites
|
||||||
|
sites: z.array(z.string()).optional().default([]),
|
||||||
// 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(),
|
||||||
|
scheme: z.enum(["http", "https"]).optional().nullable(),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
@@ -477,6 +482,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[]>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
export function encryptData(data: string, key: Buffer): string {
|
|
||||||
const algorithm = "aes-256-gcm";
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
|
||||||
|
|
||||||
let encrypted = cipher.update(data, "utf8", "hex");
|
|
||||||
encrypted += cipher.final("hex");
|
|
||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
// Combine IV, auth tag, and encrypted data
|
|
||||||
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to decrypt data (you'll need this to read certificates)
|
|
||||||
export function decryptData(encryptedData: string, key: Buffer): string {
|
|
||||||
const algorithm = "aes-256-gcm";
|
|
||||||
const parts = encryptedData.split(":");
|
|
||||||
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new Error("Invalid encrypted data format");
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = Buffer.from(parts[0], "hex");
|
|
||||||
const authTag = Buffer.from(parts[1], "hex");
|
|
||||||
const encrypted = parts[2];
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
||||||
decrypted += decipher.final("utf8");
|
|
||||||
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// openssl rand -hex 32 > config/encryption.key
|
|
||||||
116
server/lib/ip.ts
116
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 "#dynamic/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.`
|
||||||
@@ -599,7 +610,7 @@ export function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let target: SubnetProxyTargetV2 | null = null;
|
let targets: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -614,40 +625,102 @@ export function generateSubnetProxyTargetV2(
|
|||||||
if (ipSchema.safeParse(destination).success) {
|
if (ipSchema.safeParse(destination).success) {
|
||||||
destination = `${destination}/32`;
|
destination = `${destination}/32`;
|
||||||
|
|
||||||
target = {
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: destination,
|
destPrefix: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (siteResource.alias && siteResource.aliasAddress) {
|
if (siteResource.alias && siteResource.aliasAddress) {
|
||||||
// also push a match for the alias address
|
// also push a match for the alias address
|
||||||
target = {
|
targets.push({
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
|
rewriteTo: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (siteResource.mode == "cidr") {
|
||||||
|
targets.push({
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: siteResource.destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
} else if (siteResource.mode == "http") {
|
||||||
|
let destination = siteResource.destination;
|
||||||
|
// check if this is a valid ip
|
||||||
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
|
if (ipSchema.safeParse(destination).success) {
|
||||||
|
destination = `${destination}/32`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!siteResource.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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.push({
|
||||||
sourcePrefixes: [],
|
sourcePrefixes: [],
|
||||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
rewriteTo: destination,
|
rewriteTo: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId,
|
||||||
};
|
protocol: siteResource.ssl ? "https" : "http",
|
||||||
|
httpTargets: [
|
||||||
|
{
|
||||||
|
destAddr: siteResource.destination,
|
||||||
|
destPort: siteResource.destinationPort,
|
||||||
|
scheme: siteResource.scheme
|
||||||
}
|
}
|
||||||
} else if (siteResource.mode == "cidr") {
|
],
|
||||||
target = {
|
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
|
||||||
sourcePrefixes: [],
|
});
|
||||||
destPrefix: siteResource.destination,
|
|
||||||
portRange,
|
|
||||||
disableIcmp,
|
|
||||||
resourceId: siteResource.siteResourceId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (targets.length == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
for (const clientSite of clients) {
|
for (const clientSite of clients) {
|
||||||
if (!clientSite.subnet) {
|
if (!clientSite.subnet) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -661,16 +734,16 @@ export function generateSubnetProxyTargetV2(
|
|||||||
// add client prefix to source prefixes
|
// add client prefix to source prefixes
|
||||||
target.sourcePrefixes.push(clientPrefix);
|
target.sourcePrefixes.push(clientPrefix);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// print a nice representation of the targets
|
// print a nice representation of the targets
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
|
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
|
||||||
// );
|
// );
|
||||||
|
|
||||||
return target;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -697,7 +770,6 @@ export function generateSubnetProxyTargetV2(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 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
|
||||||
export const portRangeStringSchema = z
|
export const portRangeStringSchema = z
|
||||||
|
|||||||
@@ -11,17 +11,16 @@ import {
|
|||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
Site,
|
Site,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
Transaction,
|
Transaction,
|
||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userOrgs,
|
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addPeer as newtAddPeer,
|
|
||||||
deletePeer as newtDeletePeer
|
deletePeer as newtDeletePeer
|
||||||
} from "@server/routers/newt/peers";
|
} from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +34,6 @@ import {
|
|||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
generateSubnetProxyTargetV2,
|
generateSubnetProxyTargetV2,
|
||||||
parseEndpoint,
|
parseEndpoint,
|
||||||
formatEndpoint
|
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import {
|
import {
|
||||||
addPeerData,
|
addPeerData,
|
||||||
@@ -48,15 +46,27 @@ export async function getClientSiteResourceAccess(
|
|||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
) {
|
) {
|
||||||
// get the site
|
// get all sites associated with this siteResource via its network
|
||||||
const [site] = await trx
|
const sitesList = siteResource.networkId
|
||||||
|
? await trx
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.siteId, siteResource.siteId))
|
.innerJoin(
|
||||||
.limit(1);
|
siteNetworks,
|
||||||
|
eq(siteNetworks.siteId, sites.siteId)
|
||||||
|
)
|
||||||
|
.where(eq(siteNetworks.networkId, siteResource.networkId))
|
||||||
|
.then((rows) => rows.map((row) => row.sites))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (!site) {
|
logger.debug(
|
||||||
throw new Error(`Site with ID ${siteResource.siteId} not found`);
|
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sitesList.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = await trx
|
const roleIds = await trx
|
||||||
@@ -136,8 +146,12 @@ export async function getClientSiteResourceAccess(
|
|||||||
const mergedAllClients = Array.from(allClientsMap.values());
|
const mergedAllClients = Array.from(allClientsMap.values());
|
||||||
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
|
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
site,
|
sitesList,
|
||||||
mergedAllClients,
|
mergedAllClients,
|
||||||
mergedAllClientIds
|
mergedAllClientIds
|
||||||
};
|
};
|
||||||
@@ -153,17 +167,26 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const siteId = siteResource.siteId;
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
|
);
|
||||||
|
|
||||||
const { site, mergedAllClients, mergedAllClientIds } =
|
const { sitesList, mergedAllClients, mergedAllClientIds } =
|
||||||
await getClientSiteResourceAccess(siteResource, trx);
|
await getClientSiteResourceAccess(siteResource, trx);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
/////////// process the client-siteResource associations ///////////
|
/////////// process the client-siteResource associations ///////////
|
||||||
|
|
||||||
// get all of the clients associated with other resources on this site
|
// get all of the clients associated with other resources in the same network,
|
||||||
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
|
// joined through siteNetworks so we know which siteId each client belongs to
|
||||||
|
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
|
||||||
|
? await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
siteId: siteNetworks.siteId
|
||||||
})
|
})
|
||||||
.from(clientSiteResourcesAssociationsCache)
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -173,20 +196,30 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
siteResources.siteResourceId
|
siteResources.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteNetworks.networkId, siteResources.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.siteId, siteId),
|
eq(siteResources.networkId, siteResource.networkId),
|
||||||
ne(siteResources.siteResourceId, siteResource.siteResourceId)
|
ne(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
siteResource.siteResourceId
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
const allClientIdsFromOtherResourcesOnThisSite = Array.from(
|
// Build a per-site map so the loop below can check by siteId rather than
|
||||||
new Set(
|
// across the entire network.
|
||||||
allUpdatedClientsFromOtherResourcesOnThisSite.map(
|
const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
|
||||||
(row) => row.clientId
|
for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
|
||||||
)
|
if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
|
||||||
)
|
clientsFromOtherResourcesBySite.set(row.siteId, new Set());
|
||||||
);
|
}
|
||||||
|
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
|
||||||
|
}
|
||||||
|
|
||||||
const existingClientSiteResources = await trx
|
const existingClientSiteResources = await trx
|
||||||
.select({
|
.select({
|
||||||
@@ -204,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
(row) => row.clientId
|
(row) => row.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
// Get full client details for existing resource clients (needed for sending delete messages)
|
// Get full client details for existing resource clients (needed for sending delete messages)
|
||||||
const existingResourceClients =
|
const existingResourceClients =
|
||||||
existingClientSiteResourceIds.length > 0
|
existingClientSiteResourceIds.length > 0
|
||||||
@@ -223,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
(clientId) => !existingClientSiteResourceIds.includes(clientId)
|
(clientId) => !existingClientSiteResourceIds.includes(clientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map(
|
const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map(
|
||||||
(clientId) => ({
|
(clientId) => ({
|
||||||
clientId,
|
clientId,
|
||||||
@@ -231,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (clientSiteResourcesToInsert.length > 0) {
|
if (clientSiteResourcesToInsert.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)`
|
||||||
|
);
|
||||||
await trx
|
await trx
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
.insert(clientSiteResourcesAssociationsCache)
|
||||||
.values(clientSiteResourcesToInsert)
|
.values(clientSiteResourcesToInsert)
|
||||||
.returning();
|
.returning();
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter(
|
const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter(
|
||||||
(clientId) => !mergedAllClientIds.includes(clientId)
|
(clientId) => !mergedAllClientIds.includes(clientId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
if (clientSiteResourcesToRemove.length > 0) {
|
if (clientSiteResourcesToRemove.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)`
|
||||||
|
);
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSiteResourcesAssociationsCache)
|
.delete(clientSiteResourcesAssociationsCache)
|
||||||
.where(
|
.where(
|
||||||
@@ -260,31 +318,55 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
|
|
||||||
/////////// process the client-site associations ///////////
|
/////////// process the client-site associations ///////////
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const site of sitesList) {
|
||||||
|
const siteId = site.siteId;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||||
|
);
|
||||||
|
|
||||||
const existingClientSites = await trx
|
const existingClientSites = await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clientSitesAssociationsCache.clientId
|
clientId: clientSitesAssociationsCache.clientId
|
||||||
})
|
})
|
||||||
.from(clientSitesAssociationsCache)
|
.from(clientSitesAssociationsCache)
|
||||||
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
|
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||||
|
|
||||||
const existingClientSiteIds = existingClientSites.map(
|
const existingClientSiteIds = existingClientSites.map(
|
||||||
(row) => row.clientId
|
(row) => row.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
// Get full client details for existing clients (needed for sending delete messages)
|
// Get full client details for existing clients (needed for sending delete messages)
|
||||||
const existingClients = await trx
|
const existingClients =
|
||||||
|
existingClientSiteIds.length > 0
|
||||||
|
? await trx
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
pubKey: clients.pubKey,
|
pubKey: clients.pubKey,
|
||||||
subnet: clients.subnet
|
subnet: clients.subnet
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(inArray(clients.clientId, existingClientSiteIds));
|
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||||
(clientId) =>
|
(clientId) =>
|
||||||
!existingClientSiteIds.includes(clientId) &&
|
!existingClientSiteIds.includes(clientId) &&
|
||||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||||
);
|
);
|
||||||
|
|
||||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||||
@@ -292,21 +374,42 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
siteId
|
siteId
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||||
|
);
|
||||||
|
|
||||||
if (clientSitesToInsert.length > 0) {
|
if (clientSitesToInsert.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||||
|
);
|
||||||
await trx
|
await trx
|
||||||
.insert(clientSitesAssociationsCache)
|
.insert(clientSitesAssociationsCache)
|
||||||
.values(clientSitesToInsert)
|
.values(clientSitesToInsert)
|
||||||
.returning();
|
.returning();
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now remove any client-site associations that should no longer exist
|
// Now remove any client-site associations that should no longer exist
|
||||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||||
(clientId) =>
|
(clientId) =>
|
||||||
!mergedAllClientIds.includes(clientId) &&
|
!mergedAllClientIds.includes(clientId) &&
|
||||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (clientSitesToRemove.length > 0) {
|
if (clientSitesToRemove.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||||
|
);
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSitesAssociationsCache)
|
.delete(clientSitesAssociationsCache)
|
||||||
.where(
|
.where(
|
||||||
@@ -320,8 +423,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/////////// send the messages ///////////
|
|
||||||
|
|
||||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||||
await handleMessagesForSiteClients(
|
await handleMessagesForSiteClients(
|
||||||
site,
|
site,
|
||||||
@@ -332,10 +433,12 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
clientSitesToRemove,
|
clientSitesToRemove,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle subnet proxy target updates for the resource associations
|
// Handle subnet proxy target updates for the resource associations
|
||||||
await handleSubnetProxyTargetUpdates(
|
await handleSubnetProxyTargetUpdates(
|
||||||
siteResource,
|
siteResource,
|
||||||
|
sitesList,
|
||||||
mergedAllClients,
|
mergedAllClients,
|
||||||
existingResourceClients,
|
existingResourceClients,
|
||||||
clientSiteResourcesToAdd,
|
clientSiteResourcesToAdd,
|
||||||
@@ -624,6 +727,7 @@ export async function updateClientSiteDestinations(
|
|||||||
|
|
||||||
async function handleSubnetProxyTargetUpdates(
|
async function handleSubnetProxyTargetUpdates(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
|
sitesList: Site[],
|
||||||
allClients: {
|
allClients: {
|
||||||
clientId: number;
|
clientId: number;
|
||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
@@ -638,22 +742,26 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
clientSiteResourcesToRemove: number[],
|
clientSiteResourcesToRemove: number[],
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const proxyJobs: Promise<any>[] = [];
|
||||||
|
const olmJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
|
for (const siteData of sitesList) {
|
||||||
|
const siteId = siteData.siteId;
|
||||||
|
|
||||||
// Get the newt for this site
|
// Get the newt for this site
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, siteResource.siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
|
`Newt not found for site ${siteId}, skipping subnet proxy target updates`
|
||||||
);
|
);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyJobs = [];
|
|
||||||
const olmJobs = [];
|
|
||||||
// Generate targets for added associations
|
// Generate targets for added associations
|
||||||
if (clientSiteResourcesToAdd.length > 0) {
|
if (clientSiteResourcesToAdd.length > 0) {
|
||||||
const addedClients = allClients.filter((client) =>
|
const addedClients = allClients.filter((client) =>
|
||||||
@@ -661,16 +769,16 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (addedClients.length > 0) {
|
if (addedClients.length > 0) {
|
||||||
const targetToAdd = generateSubnetProxyTargetV2(
|
const targetsToAdd = await generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
addedClients
|
addedClients
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetToAdd) {
|
if (targetsToAdd) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
addSubnetProxyTargets(
|
addSubnetProxyTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
[targetToAdd],
|
targetsToAdd,
|
||||||
newt.version
|
newt.version
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -680,7 +788,7 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
addPeerData(
|
addPeerData(
|
||||||
client.clientId,
|
client.clientId,
|
||||||
siteResource.siteId,
|
siteId,
|
||||||
generateRemoteSubnets([siteResource]),
|
generateRemoteSubnets([siteResource]),
|
||||||
generateAliasConfig([siteResource])
|
generateAliasConfig([siteResource])
|
||||||
)
|
)
|
||||||
@@ -698,23 +806,27 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (removedClients.length > 0) {
|
if (removedClients.length > 0) {
|
||||||
const targetToRemove = generateSubnetProxyTargetV2(
|
const targetsToRemove = await generateSubnetProxyTargetV2(
|
||||||
siteResource,
|
siteResource,
|
||||||
removedClients
|
removedClients
|
||||||
);
|
);
|
||||||
|
|
||||||
if (targetToRemove) {
|
if (targetsToRemove) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(
|
removeSubnetProxyTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
[targetToRemove],
|
targetsToRemove,
|
||||||
newt.version
|
newt.version
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const client of removedClients) {
|
for (const client of removedClients) {
|
||||||
// Check if this client still has access to another resource on this site with the same destination
|
// Check if this client still has access to another resource
|
||||||
|
// on this specific site with the same destination. We scope
|
||||||
|
// by siteId (via siteNetworks) rather than networkId because
|
||||||
|
// removePeerData operates per-site — a resource on a different
|
||||||
|
// site sharing the same network should not block removal here.
|
||||||
const destinationStillInUse = await trx
|
const destinationStillInUse = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -725,13 +837,17 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
siteResources.siteResourceId
|
siteResources.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteNetworks.networkId, siteResources.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
client.clientId
|
client.clientId
|
||||||
),
|
),
|
||||||
eq(siteResources.siteId, siteResource.siteId),
|
eq(siteNetworks.siteId, siteId),
|
||||||
eq(
|
eq(
|
||||||
siteResources.destination,
|
siteResources.destination,
|
||||||
siteResource.destination
|
siteResource.destination
|
||||||
@@ -752,7 +868,7 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
removePeerData(
|
removePeerData(
|
||||||
client.clientId,
|
client.clientId,
|
||||||
siteResource.siteId,
|
siteId,
|
||||||
remoteSubnetsToRemove,
|
remoteSubnetsToRemove,
|
||||||
generateAliasConfig([siteResource])
|
generateAliasConfig([siteResource])
|
||||||
)
|
)
|
||||||
@@ -760,6 +876,7 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all(proxyJobs);
|
await Promise.all(proxyJobs);
|
||||||
}
|
}
|
||||||
@@ -863,10 +980,25 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Group by siteId for site-level associations
|
// Group by siteId for site-level associations — look up via siteNetworks since
|
||||||
const newSiteIds = Array.from(
|
// siteResources no longer carries a direct siteId column.
|
||||||
new Set(newSiteResources.map((sr) => sr.siteId))
|
const networkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
newSiteResources
|
||||||
|
.map((sr) => sr.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const newSiteIds =
|
||||||
|
networkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, networkIds))
|
||||||
|
.then((rows) =>
|
||||||
|
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
/////////// Process client-siteResource associations ///////////
|
/////////// Process client-siteResource associations ///////////
|
||||||
|
|
||||||
@@ -1139,13 +1271,45 @@ async function handleMessagesForClientResources(
|
|||||||
resourcesToAdd.includes(r.siteResourceId)
|
resourcesToAdd.includes(r.siteResourceId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
|
||||||
|
const addedNetworkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
addedResources
|
||||||
|
.map((r) => r.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const addedSiteNetworkRows =
|
||||||
|
addedNetworkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({
|
||||||
|
networkId: siteNetworks.networkId,
|
||||||
|
siteId: siteNetworks.siteId
|
||||||
|
})
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, addedNetworkIds))
|
||||||
|
: [];
|
||||||
|
const addedNetworkToSites = new Map<number, number[]>();
|
||||||
|
for (const row of addedSiteNetworkRows) {
|
||||||
|
if (!addedNetworkToSites.has(row.networkId)) {
|
||||||
|
addedNetworkToSites.set(row.networkId, []);
|
||||||
|
}
|
||||||
|
addedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
// Group by site for proxy updates
|
// Group by site for proxy updates
|
||||||
const addedBySite = new Map<number, SiteResource[]>();
|
const addedBySite = new Map<number, SiteResource[]>();
|
||||||
for (const resource of addedResources) {
|
for (const resource of addedResources) {
|
||||||
if (!addedBySite.has(resource.siteId)) {
|
const siteIds =
|
||||||
addedBySite.set(resource.siteId, []);
|
resource.networkId != null
|
||||||
|
? (addedNetworkToSites.get(resource.networkId) ?? [])
|
||||||
|
: [];
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
if (!addedBySite.has(siteId)) {
|
||||||
|
addedBySite.set(siteId, []);
|
||||||
|
}
|
||||||
|
addedBySite.get(siteId)!.push(resource);
|
||||||
}
|
}
|
||||||
addedBySite.get(resource.siteId)!.push(resource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add subnet proxy targets for each site
|
// Add subnet proxy targets for each site
|
||||||
@@ -1164,7 +1328,7 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const target = generateSubnetProxyTargetV2(resource, [
|
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||||
{
|
{
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
pubKey: client.pubKey,
|
pubKey: client.pubKey,
|
||||||
@@ -1172,11 +1336,11 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (target) {
|
if (targets) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
addSubnetProxyTargets(
|
addSubnetProxyTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
[target],
|
targets,
|
||||||
newt.version
|
newt.version
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1187,7 +1351,7 @@ async function handleMessagesForClientResources(
|
|||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
addPeerData(
|
addPeerData(
|
||||||
client.clientId,
|
client.clientId,
|
||||||
resource.siteId,
|
siteId,
|
||||||
generateRemoteSubnets([resource]),
|
generateRemoteSubnets([resource]),
|
||||||
generateAliasConfig([resource])
|
generateAliasConfig([resource])
|
||||||
)
|
)
|
||||||
@@ -1199,7 +1363,7 @@ async function handleMessagesForClientResources(
|
|||||||
error.message.includes("not found")
|
error.message.includes("not found")
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1216,13 +1380,45 @@ async function handleMessagesForClientResources(
|
|||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
|
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
|
||||||
|
|
||||||
|
// Build (resource, siteId) pairs via siteNetworks
|
||||||
|
const removedNetworkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
removedResources
|
||||||
|
.map((r) => r.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const removedSiteNetworkRows =
|
||||||
|
removedNetworkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({
|
||||||
|
networkId: siteNetworks.networkId,
|
||||||
|
siteId: siteNetworks.siteId
|
||||||
|
})
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, removedNetworkIds))
|
||||||
|
: [];
|
||||||
|
const removedNetworkToSites = new Map<number, number[]>();
|
||||||
|
for (const row of removedSiteNetworkRows) {
|
||||||
|
if (!removedNetworkToSites.has(row.networkId)) {
|
||||||
|
removedNetworkToSites.set(row.networkId, []);
|
||||||
|
}
|
||||||
|
removedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||||
|
}
|
||||||
|
|
||||||
// Group by site for proxy updates
|
// Group by site for proxy updates
|
||||||
const removedBySite = new Map<number, SiteResource[]>();
|
const removedBySite = new Map<number, SiteResource[]>();
|
||||||
for (const resource of removedResources) {
|
for (const resource of removedResources) {
|
||||||
if (!removedBySite.has(resource.siteId)) {
|
const siteIds =
|
||||||
removedBySite.set(resource.siteId, []);
|
resource.networkId != null
|
||||||
|
? (removedNetworkToSites.get(resource.networkId) ?? [])
|
||||||
|
: [];
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
if (!removedBySite.has(siteId)) {
|
||||||
|
removedBySite.set(siteId, []);
|
||||||
|
}
|
||||||
|
removedBySite.get(siteId)!.push(resource);
|
||||||
}
|
}
|
||||||
removedBySite.get(resource.siteId)!.push(resource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove subnet proxy targets for each site
|
// Remove subnet proxy targets for each site
|
||||||
@@ -1241,7 +1437,7 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const target = generateSubnetProxyTargetV2(resource, [
|
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||||
{
|
{
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
pubKey: client.pubKey,
|
pubKey: client.pubKey,
|
||||||
@@ -1249,18 +1445,22 @@ async function handleMessagesForClientResources(
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (target) {
|
if (targets) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(
|
removeSubnetProxyTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
[target],
|
targets,
|
||||||
newt.version
|
newt.version
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if this client still has access to another resource on this site with the same destination
|
// Check if this client still has access to another resource
|
||||||
|
// on this specific site with the same destination. We scope
|
||||||
|
// by siteId (via siteNetworks) rather than networkId because
|
||||||
|
// removePeerData operates per-site — a resource on a different
|
||||||
|
// site sharing the same network should not block removal here.
|
||||||
const destinationStillInUse = await trx
|
const destinationStillInUse = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -1271,13 +1471,17 @@ async function handleMessagesForClientResources(
|
|||||||
siteResources.siteResourceId
|
siteResources.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteNetworks.networkId, siteResources.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
client.clientId
|
client.clientId
|
||||||
),
|
),
|
||||||
eq(siteResources.siteId, resource.siteId),
|
eq(siteNetworks.siteId, siteId),
|
||||||
eq(
|
eq(
|
||||||
siteResources.destination,
|
siteResources.destination,
|
||||||
resource.destination
|
resource.destination
|
||||||
@@ -1299,7 +1503,7 @@ async function handleMessagesForClientResources(
|
|||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
removePeerData(
|
removePeerData(
|
||||||
client.clientId,
|
client.clientId,
|
||||||
resource.siteId,
|
siteId,
|
||||||
remoteSubnetsToRemove,
|
remoteSubnetsToRemove,
|
||||||
generateAliasConfig([resource])
|
generateAliasConfig([resource])
|
||||||
)
|
)
|
||||||
@@ -1311,7 +1515,7 @@ async function handleMessagesForClientResources(
|
|||||||
error.message.includes("not found")
|
error.message.includes("not found")
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
478
server/private/lib/acmeCertSync.ts
Normal file
478
server/private/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import {
|
||||||
|
certificates,
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
db,
|
||||||
|
domains,
|
||||||
|
newts,
|
||||||
|
siteNetworks,
|
||||||
|
SiteResource,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
generateSubnetProxyTargetV2,
|
||||||
|
SubnetProxyTargetV2
|
||||||
|
} from "@server/lib/ip";
|
||||||
|
import { updateTargets } from "@server/routers/client/targets";
|
||||||
|
import cache from "#private/lib/cache";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
interface AcmeCert {
|
||||||
|
domain: { main: string; sans?: string[] };
|
||||||
|
certificate: string;
|
||||||
|
key: string;
|
||||||
|
Store: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcmeJson {
|
||||||
|
[resolver: string]: {
|
||||||
|
Certificates: AcmeCert[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushCertUpdateToAffectedNewts(
|
||||||
|
domain: string,
|
||||||
|
domainId: string | null,
|
||||||
|
oldCertPem: string | null,
|
||||||
|
oldKeyPem: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||||
|
let affectedResources: SiteResource[] = [];
|
||||||
|
|
||||||
|
if (domainId) {
|
||||||
|
affectedResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.domainId, domainId),
|
||||||
|
eq(siteResources.ssl, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: match by exact fullDomain when no domainId is available
|
||||||
|
affectedResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.fullDomain, domain),
|
||||||
|
eq(siteResources.ssl, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affectedResources.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const resource of affectedResources) {
|
||||||
|
try {
|
||||||
|
// Get all sites for this resource via siteNetworks
|
||||||
|
const resourceSiteRows = resource.networkId
|
||||||
|
? await db
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (resourceSiteRows.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all clients with access to this resource
|
||||||
|
const resourceClients = await db
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
pubKey: clients.pubKey,
|
||||||
|
subnet: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.innerJoin(
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
eq(
|
||||||
|
clients.clientId,
|
||||||
|
clientSiteResourcesAssociationsCache.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceClients.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||||
|
if (resource.fullDomain) {
|
||||||
|
await cache.del(`cert:${resource.fullDomain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate target once — same cert applies to all sites for this resource
|
||||||
|
const newTargets = await generateSubnetProxyTargetV2(
|
||||||
|
resource,
|
||||||
|
resourceClients
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newTargets) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the old targets — same routing shape but with the previous cert/key.
|
||||||
|
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||||
|
// semantics correct so the update message accurately reflects what changed.
|
||||||
|
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||||
|
...t,
|
||||||
|
tlsCert: oldCertPem ?? undefined,
|
||||||
|
tlsKey: oldKeyPem ?? undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Push update to each site's newt
|
||||||
|
for (const { siteId } of resourceSiteRows) {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTargets(
|
||||||
|
newt.newtId,
|
||||||
|
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||||
|
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||||
|
const lookupDomain = certDomain.startsWith("*.")
|
||||||
|
? certDomain.slice(2)
|
||||||
|
: certDomain;
|
||||||
|
|
||||||
|
// 1. Exact baseDomain match (any domain type)
|
||||||
|
const exactMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.baseDomain, lookupDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (exactMatch.length > 0) {
|
||||||
|
return exactMatch[0].domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||||
|
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||||
|
// matches a wildcard domain with baseDomain "example.com".
|
||||||
|
const parts = lookupDomain.split(".");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const candidate = parts.slice(i).join(".");
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
const wildcardMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.baseDomain, candidate),
|
||||||
|
eq(domains.type, "wildcard")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (wildcardMatch.length > 0) {
|
||||||
|
return wildcardMatch[0].domainId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstCert(pemBundle: string): string | null {
|
||||||
|
const match = pemBundle.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(
|
||||||
|
acmeJsonPath: string,
|
||||||
|
resolver: string
|
||||||
|
): Promise<void> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acmeJson: AcmeJson;
|
||||||
|
try {
|
||||||
|
acmeJson = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
const domain = cert.domain?.main;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert.certificate || !cert.key) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
|
||||||
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cert already exists in DB
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let oldCertPem: string | null = null;
|
||||||
|
let oldKeyPem: string | null = null;
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decrypt(
|
||||||
|
existing[0].certFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
if (storedCertPem === certPem) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cert has changed; capture old values so we can send a correct
|
||||||
|
// update message to the newt after the DB write.
|
||||||
|
oldCertPem = storedCertPem;
|
||||||
|
if (existing[0].keyFile) {
|
||||||
|
try {
|
||||||
|
oldKeyPem = decrypt(
|
||||||
|
existing[0].keyFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} catch (keyErr) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Decryption failure means we should proceed with the update
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cert expiry from the first cert in the PEM bundle
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
const firstCertPem = extractFirstCert(certPem);
|
||||||
|
if (firstCertPem) {
|
||||||
|
try {
|
||||||
|
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||||
|
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
const encryptedCert = encrypt(
|
||||||
|
certPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const encryptedKey = encrypt(
|
||||||
|
keyPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
oldCertPem,
|
||||||
|
oldKeyPem
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// For a brand-new cert, push to any SSL resources that were waiting for it
|
||||||
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAcmeCertSync(): void {
|
||||||
|
if (build == "saas") {
|
||||||
|
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateConfigData = privateConfig.getRawPrivateConfig();
|
||||||
|
|
||||||
|
if (!privateConfigData.flags?.enable_acme_cert_sync) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateConfigData.flags.use_pangolin_dns) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acmeJsonPath =
|
||||||
|
privateConfigData.acme?.acme_json_path ??
|
||||||
|
"config/letsencrypt/acme.json";
|
||||||
|
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||||
|
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run immediately on init, then on the configured interval
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
@@ -11,23 +11,15 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import config from "./config";
|
import privateConfig from "./config";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import { certificates, db } from "@server/db";
|
import { certificates, db } from "@server/db";
|
||||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||||
import { decryptData } from "@server/lib/encryption";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import cache from "#private/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
|
|
||||||
let encryptionKeyHex = "";
|
|
||||||
let encryptionKey: Buffer;
|
|
||||||
function loadEncryptData() {
|
|
||||||
if (encryptionKey) {
|
|
||||||
return; // already loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
|
||||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the return type for clarity and type safety
|
// Define the return type for clarity and type safety
|
||||||
export type CertificateResult = {
|
export type CertificateResult = {
|
||||||
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
domains: Set<string>,
|
domains: Set<string>,
|
||||||
useCache: boolean = true
|
useCache: boolean = true
|
||||||
): Promise<Array<CertificateResult>> {
|
): Promise<Array<CertificateResult>> {
|
||||||
loadEncryptData(); // Ensure encryption key is loaded
|
|
||||||
|
|
||||||
const finalResults: CertificateResult[] = [];
|
const finalResults: CertificateResult[] = [];
|
||||||
const domainsToQuery = new Set<string>();
|
const domainsToQuery = new Set<string>();
|
||||||
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
|
|||||||
|
|
||||||
// 2. If all domains were resolved from the cache, return early
|
// 2. If all domains were resolved from the cache, return early
|
||||||
if (domainsToQuery.size === 0) {
|
if (domainsToQuery.size === 0) {
|
||||||
const decryptedResults = decryptFinalResults(finalResults);
|
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedResults = decryptFinalResults(finalResults);
|
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||||
return decryptedResults;
|
return decryptedResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptFinalResults(
|
function decryptFinalResults(
|
||||||
finalResults: CertificateResult[]
|
finalResults: CertificateResult[],
|
||||||
|
secret: string
|
||||||
): CertificateResult[] {
|
): CertificateResult[] {
|
||||||
const validCertsDecrypted = finalResults.map((cert) => {
|
const validCertsDecrypted = finalResults.map((cert) => {
|
||||||
// Decrypt and save certificate file
|
// Decrypt and save certificate file
|
||||||
const decryptedCert = decryptData(
|
const decryptedCert = decrypt(
|
||||||
cert.certFile!, // is not null from query
|
cert.certFile!, // is not null from query
|
||||||
encryptionKey
|
secret
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt and save key file
|
// Decrypt and save key file
|
||||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||||
|
|
||||||
// Return only the certificate data without org information
|
// Return only the certificate data without org information
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
server: z
|
server: z
|
||||||
.object({
|
.object({
|
||||||
encryption_key: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
|
||||||
reo_client_id: z
|
reo_client_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -95,10 +91,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(true)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
acme: z
|
||||||
|
.object({
|
||||||
|
acme_json_path: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("config/letsencrypt/acme.json"),
|
||||||
|
resolver: z.string().optional().default("letsencrypt"),
|
||||||
|
sync_interval_ms: z.number().optional().default(5000)
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
branding: z
|
branding: z
|
||||||
.object({
|
.object({
|
||||||
app_name: z.string().optional(),
|
app_name: z.string().optional(),
|
||||||
|
|||||||
@@ -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, siteNetworks, siteResources, Target, targets } from "@server/db";
|
||||||
import {
|
import {
|
||||||
sanitize,
|
sanitize,
|
||||||
encodePath,
|
encodePath,
|
||||||
@@ -267,6 +267,35 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||||
|
const siteResourcesWithFullDomain = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
fullDomain: siteResources.fullDomain,
|
||||||
|
mode: siteResources.mode
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
|
||||||
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
isNotNull(siteResources.fullDomain),
|
||||||
|
eq(siteResources.mode, "http"),
|
||||||
|
eq(siteResources.ssl, true),
|
||||||
|
or(
|
||||||
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
|
and(
|
||||||
|
isNull(sites.exitNodeId),
|
||||||
|
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||||
|
eq(sites.type, "local"),
|
||||||
|
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
inArray(sites.type, siteTypes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
@@ -276,6 +305,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 siteResourcesWithFullDomain) {
|
||||||
|
if (sr.fullDomain) {
|
||||||
|
domains.add(sr.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -867,6 +902,139 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
|
// matching resource exists yet.
|
||||||
|
if (siteResourcesWithFullDomain.length > 0) {
|
||||||
|
// Build a set of domains already covered by normal resources
|
||||||
|
const existingFullDomains = new Set<string>();
|
||||||
|
for (const resource of resourcesMap.values()) {
|
||||||
|
if (resource.fullDomain) {
|
||||||
|
existingFullDomains.add(resource.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sr of siteResourcesWithFullDomain) {
|
||||||
|
if (!sr.fullDomain) continue;
|
||||||
|
|
||||||
|
// Skip if this alias is already handled by a resource router
|
||||||
|
if (existingFullDomains.has(sr.fullDomain)) continue;
|
||||||
|
|
||||||
|
const fullDomain = sr.fullDomain;
|
||||||
|
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||||
|
const siteResourceServiceName = `${srKey}-service`;
|
||||||
|
const siteResourceRouterName = `${srKey}-router`;
|
||||||
|
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
||||||
|
|
||||||
|
const maintenancePort = config.getRawConfig().server.next_port;
|
||||||
|
const maintenanceHost =
|
||||||
|
config.getRawConfig().server.internal_hostname;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) {
|
||||||
|
config_output.http.routers = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.services) {
|
||||||
|
config_output.http.services = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service pointing at the internal maintenance/Next.js page
|
||||||
|
config_output.http.services[siteResourceServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
passHostHeader: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware that rewrites any path to /maintenance-screen
|
||||||
|
config_output.http.middlewares[
|
||||||
|
siteResourceRewriteMiddlewareName
|
||||||
|
] = {
|
||||||
|
replacePathRegex: {
|
||||||
|
regex: "^/(.*)",
|
||||||
|
replacement: "/private-maintenance-screen"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||||
|
config_output.http.routers[
|
||||||
|
`${siteResourceRouterName}-redirect`
|
||||||
|
] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine TLS / cert-resolver configuration
|
||||||
|
let tls: any = {};
|
||||||
|
if (
|
||||||
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
|
) {
|
||||||
|
const domainParts = fullDomain.split(".");
|
||||||
|
const wildCard =
|
||||||
|
domainParts.length <= 2
|
||||||
|
? `*.${domainParts.join(".")}`
|
||||||
|
: `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: globalDefaultResolver,
|
||||||
|
...(globalDefaultPreferWildcard
|
||||||
|
? { domains: [{ main: wildCard }] }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// pangolin-dns: only add route if we already have a valid cert
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for siteResource alias: ${fullDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS router — presence of this entry triggers cert generation
|
||||||
|
config_output.http.routers[siteResourceRouterName] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
|
],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
middlewares: [siteResourceRewriteMiddlewareName],
|
||||||
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
|
priority: 100,
|
||||||
|
tls
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assets bypass router — lets Next.js static files load without rewrite
|
||||||
|
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
|
],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||||
|
priority: 101,
|
||||||
|
tls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (generateLoginPageRouters) {
|
if (generateLoginPageRouters) {
|
||||||
const exitNodeLoginPages = await db
|
const exitNodeLoginPages = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
certificates,
|
certificates,
|
||||||
exitNodeOrgs,
|
exitNodeOrgs,
|
||||||
RemoteExitNode,
|
|
||||||
olms,
|
|
||||||
newts,
|
|
||||||
clients,
|
|
||||||
sites,
|
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
targets,
|
|
||||||
loginPage,
|
loginPage,
|
||||||
loginPageOrg,
|
loginPageOrg,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
@@ -70,12 +64,9 @@ import {
|
|||||||
updateAndGenerateEndpointDestinations,
|
updateAndGenerateEndpointDestinations,
|
||||||
updateSiteBandwidth
|
updateSiteBandwidth
|
||||||
} from "@server/routers/gerbil";
|
} from "@server/routers/gerbil";
|
||||||
import * as gerbil from "@server/routers/gerbil";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { decryptData } from "@server/lib/encryption";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import { exchangeSession } from "@server/routers/badger";
|
import { exchangeSession } from "@server/routers/badger";
|
||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||||
@@ -298,25 +289,11 @@ hybridRouter.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let encryptionKeyHex = "";
|
|
||||||
let encryptionKey: Buffer;
|
|
||||||
function loadEncryptData() {
|
|
||||||
if (encryptionKey) {
|
|
||||||
return; // already loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptionKeyHex =
|
|
||||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
|
||||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get valid certificates for given domains (supports wildcard certs)
|
// Get valid certificates for given domains (supports wildcard certs)
|
||||||
hybridRouter.get(
|
hybridRouter.get(
|
||||||
"/certificates/domains",
|
"/certificates/domains",
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
loadEncryptData(); // Ensure encryption key is loaded
|
|
||||||
|
|
||||||
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
||||||
req.query
|
req.query
|
||||||
);
|
);
|
||||||
@@ -447,13 +424,13 @@ hybridRouter.get(
|
|||||||
|
|
||||||
const result = filtered.map((cert) => {
|
const result = filtered.map((cert) => {
|
||||||
// Decrypt and save certificate file
|
// Decrypt and save certificate file
|
||||||
const decryptedCert = decryptData(
|
const decryptedCert = decrypt(
|
||||||
cert.certFile!, // is not null from query
|
cert.certFile!, // is not null from query
|
||||||
encryptionKey
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
|
|
||||||
// Decrypt and save key file
|
// Decrypt and save key file
|
||||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||||
|
|
||||||
// Return only the certificate data without org information
|
// Return only the certificate data without org information
|
||||||
return {
|
return {
|
||||||
@@ -833,9 +810,12 @@ hybridRouter.get(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
|
logger.debug(
|
||||||
|
`User ${userId} has roles in org ${orgId}:`,
|
||||||
|
userOrgRoleRows
|
||||||
|
);
|
||||||
|
|
||||||
return response<{ roleId: number, roleName: string }[]>(res, {
|
return response<{ roleId: number; roleName: string }[]>(res, {
|
||||||
data: userOrgRoleRows,
|
data: userOrgRoleRows,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the org for this site
|
// Look up the org for this site and check retention settings
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
|
.select({
|
||||||
|
orgId: sites.orgId,
|
||||||
|
orgSubnet: orgs.subnet,
|
||||||
|
settingsLogRetentionDaysConnection:
|
||||||
|
orgs.settingsLogRetentionDaysConnection
|
||||||
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
.where(eq(sites.siteId, newt.siteId));
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
const orgId = site.orgId;
|
const orgId = site.orgId;
|
||||||
|
|
||||||
|
if (site.settingsLogRetentionDaysConnection === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Connection log retention is disabled for org ${orgId}, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
||||||
// reconstruct the exact subnet string stored on each client record.
|
// reconstruct the exact subnet string stored on each client record.
|
||||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||||
|
|||||||
238
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
238
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { inflate } from "zlib";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||||
|
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||||
|
|
||||||
|
export async function flushRequestLogToDb(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zlibInflate = promisify(inflate);
|
||||||
|
|
||||||
|
interface HTTPRequestLogData {
|
||||||
|
requestId: string;
|
||||||
|
resourceId: number; // siteResourceId
|
||||||
|
timestamp: string; // ISO 8601
|
||||||
|
method: string;
|
||||||
|
scheme: string; // "http" or "https"
|
||||||
|
host: string;
|
||||||
|
path: string;
|
||||||
|
rawQuery?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
sourceAddr: string; // ip:port
|
||||||
|
tls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||||
|
*/
|
||||||
|
async function decompressRequestLog(
|
||||||
|
compressed: string
|
||||||
|
): Promise<HTTPRequestLogData[]> {
|
||||||
|
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||||
|
const decompressed = await zlibInflate(compressedBuffer);
|
||||||
|
const jsonString = decompressed.toString("utf-8");
|
||||||
|
const parsed = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("Decompressed request log data is not an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||||
|
const { message, client } = context;
|
||||||
|
const newt = client as Newt;
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Request log received but no newt client in context");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Request log received but newt has no siteId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.data?.compressed) {
|
||||||
|
logger.warn("Request log message missing compressed data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the org for this site and check retention settings
|
||||||
|
const [site] = await db
|
||||||
|
.select({
|
||||||
|
orgId: sites.orgId,
|
||||||
|
orgSubnet: orgs.subnet,
|
||||||
|
settingsLogRetentionDaysRequest:
|
||||||
|
orgs.settingsLogRetentionDaysRequest
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.warn(
|
||||||
|
`Request log received but site ${newt.siteId} not found in database`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = site.orgId;
|
||||||
|
|
||||||
|
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Request log retention is disabled for org ${orgId}, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: HTTPRequestLogData[];
|
||||||
|
try {
|
||||||
|
entries = await decompressRequestLog(message.data.compressed);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to decompress request log data:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||||
|
|
||||||
|
// Build a map from sourceIp → external endpoint string by joining clients
|
||||||
|
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||||
|
// of the client device and is used for GeoIP lookup.
|
||||||
|
const ipToEndpoint = new Map<string, string>();
|
||||||
|
|
||||||
|
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||||
|
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cidrSuffix) {
|
||||||
|
const uniqueSourceAddrs = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.sourceAddr) {
|
||||||
|
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueSourceAddrs.size > 0) {
|
||||||
|
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||||
|
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||||
|
return `${ip}${cidrSuffix}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchedClients = await db
|
||||||
|
.select({
|
||||||
|
subnet: clients.subnet,
|
||||||
|
endpoint: clientSitesAssociationsCache.endpoint
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.innerJoin(
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clients.clientId
|
||||||
|
),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
inArray(clients.subnet, subnetQueries)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const c of matchedClients) {
|
||||||
|
if (c.endpoint) {
|
||||||
|
const ip = c.subnet.split("/")[0];
|
||||||
|
ipToEndpoint.set(ip, c.endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (
|
||||||
|
!entry.requestId ||
|
||||||
|
!entry.resourceId ||
|
||||||
|
!entry.method ||
|
||||||
|
!entry.scheme ||
|
||||||
|
!entry.host ||
|
||||||
|
!entry.path ||
|
||||||
|
!entry.sourceAddr
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalRequestURL =
|
||||||
|
entry.scheme +
|
||||||
|
"://" +
|
||||||
|
entry.host +
|
||||||
|
entry.path +
|
||||||
|
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||||
|
|
||||||
|
// Resolve the client's external endpoint for GeoIP lookup.
|
||||||
|
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||||
|
const sourceIp = entry.sourceAddr.includes(":")
|
||||||
|
? entry.sourceAddr.split(":")[0]
|
||||||
|
: entry.sourceAddr;
|
||||||
|
const endpoint = ipToEndpoint.get(sourceIp);
|
||||||
|
let location: string | undefined;
|
||||||
|
if (endpoint) {
|
||||||
|
const endpointIp = endpoint.includes(":")
|
||||||
|
? endpoint.split(":")[0]
|
||||||
|
: endpoint;
|
||||||
|
location = await getCountryCodeForIp(endpointIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logRequestAudit(
|
||||||
|
{
|
||||||
|
action: true,
|
||||||
|
reason: 108,
|
||||||
|
siteResourceId: entry.resourceId,
|
||||||
|
orgId,
|
||||||
|
location
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: entry.path,
|
||||||
|
originalRequestURL,
|
||||||
|
scheme: entry.scheme,
|
||||||
|
host: entry.host,
|
||||||
|
method: entry.method,
|
||||||
|
tls: entry.tls,
|
||||||
|
requestIp: entry.sourceAddr
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,3 +12,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./handleConnectionLogMessage";
|
export * from "./handleConnectionLogMessage";
|
||||||
|
export * from "./handleRequestLogMessage";
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
siteNetworks,
|
||||||
userOrgs
|
userOrgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
@@ -63,10 +63,12 @@ const bodySchema = z
|
|||||||
|
|
||||||
export type SignSshKeyResponse = {
|
export type SignSshKeyResponse = {
|
||||||
certificate: string;
|
certificate: string;
|
||||||
|
messageIds: number[];
|
||||||
messageId: number;
|
messageId: number;
|
||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
|
siteIds: number[];
|
||||||
siteId: number;
|
siteId: number;
|
||||||
keyId: string;
|
keyId: string;
|
||||||
validPrincipals: string[];
|
validPrincipals: string[];
|
||||||
@@ -260,10 +262,7 @@ export async function signSshKey(
|
|||||||
.update(userOrgs)
|
.update(userOrgs)
|
||||||
.set({ pamUsername: usernameToUse })
|
.set({ pamUsername: usernameToUse })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||||
eq(userOrgs.orgId, orgId),
|
|
||||||
eq(userOrgs.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
usernameToUse = userOrg.pamUsername;
|
usernameToUse = userOrg.pamUsername;
|
||||||
@@ -395,21 +394,12 @@ export async function signSshKey(
|
|||||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the site
|
const sites = await db
|
||||||
const [newt] = await db
|
.select({ siteId: siteNetworks.siteId })
|
||||||
.select()
|
.from(siteNetworks)
|
||||||
.from(newts)
|
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||||
.where(eq(newts.siteId, resource.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!newt) {
|
const siteIds = sites.map((site) => site.siteId);
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Site associated with resource not found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the public key
|
// Sign the public key
|
||||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
@@ -423,6 +413,24 @@ export async function signSshKey(
|
|||||||
validBefore: now + validFor
|
validBefore: now + validFor
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const messageIds: number[] = [];
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
// get the site
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Site associated with resource not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [message] = await db
|
const [message] = await db
|
||||||
.insert(roundTripMessageTracker)
|
.insert(roundTripMessageTracker)
|
||||||
.values({
|
.values({
|
||||||
@@ -441,6 +449,8 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
messageIds.push(message.messageId);
|
||||||
|
|
||||||
await sendToClient(newt.newtId, {
|
await sendToClient(newt.newtId, {
|
||||||
type: `newt/pam/connection`,
|
type: `newt/pam/connection`,
|
||||||
data: {
|
data: {
|
||||||
@@ -460,6 +470,7 @@ export async function signSshKey(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const expiresIn = Number(validFor); // seconds
|
const expiresIn = Number(validFor); // seconds
|
||||||
|
|
||||||
@@ -480,7 +491,7 @@ export async function signSshKey(
|
|||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
resource: resource.name,
|
resource: resource.name,
|
||||||
siteId: resource.siteId,
|
siteIds: siteIds
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +505,7 @@ export async function signSshKey(
|
|||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
resourceName: resource.name,
|
resourceName: resource.name,
|
||||||
siteId: resource.siteId,
|
siteId: siteIds[0],
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost
|
sshHost: sshHost
|
||||||
},
|
},
|
||||||
@@ -505,11 +516,13 @@ export async function signSshKey(
|
|||||||
return response<SignSshKeyResponse>(res, {
|
return response<SignSshKeyResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
certificate: cert.certificate,
|
certificate: cert.certificate,
|
||||||
messageId: message.messageId,
|
messageIds: messageIds,
|
||||||
|
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost,
|
sshHost: sshHost,
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
siteId: resource.siteId,
|
siteIds: siteIds,
|
||||||
|
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||||
keyId: cert.keyId,
|
keyId: cert.keyId,
|
||||||
validPrincipals: cert.validPrincipals,
|
validPrincipals: cert.validPrincipals,
|
||||||
validAfter: cert.validAfter.toISOString(),
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
|
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
|
|||||||
lt(requestAuditLog.timestamp, data.timeEnd),
|
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||||
eq(requestAuditLog.orgId, data.orgId),
|
eq(requestAuditLog.orgId, data.orgId),
|
||||||
data.resourceId
|
data.resourceId
|
||||||
? eq(requestAuditLog.resourceId, data.resourceId)
|
? or(
|
||||||
|
eq(requestAuditLog.resourceId, data.resourceId),
|
||||||
|
eq(requestAuditLog.siteResourceId, data.resourceId)
|
||||||
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||||
@@ -118,6 +121,7 @@ export function queryRequest(data: Q) {
|
|||||||
actor: requestAuditLog.actor,
|
actor: requestAuditLog.actor,
|
||||||
actorId: requestAuditLog.actorId,
|
actorId: requestAuditLog.actorId,
|
||||||
resourceId: requestAuditLog.resourceId,
|
resourceId: requestAuditLog.resourceId,
|
||||||
|
siteResourceId: requestAuditLog.siteResourceId,
|
||||||
ip: requestAuditLog.ip,
|
ip: requestAuditLog.ip,
|
||||||
location: requestAuditLog.location,
|
location: requestAuditLog.location,
|
||||||
userAgent: requestAuditLog.userAgent,
|
userAgent: requestAuditLog.userAgent,
|
||||||
@@ -137,17 +141,22 @@ export function queryRequest(data: Q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
||||||
// If logs database is the same as main database, we can do a join
|
|
||||||
// Otherwise, we need to fetch resource details separately
|
|
||||||
const resourceIds = logs
|
const resourceIds = logs
|
||||||
.map(log => log.resourceId)
|
.map(log => log.resourceId)
|
||||||
.filter((id): id is number => id !== null && id !== undefined);
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
if (resourceIds.length === 0) {
|
const siteResourceIds = logs
|
||||||
|
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||||
|
.map(log => log.siteResourceId)
|
||||||
|
.filter((id): id is number => id !== null && id !== undefined);
|
||||||
|
|
||||||
|
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch resource details from main database
|
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||||
|
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
const resourceDetails = await primaryDb
|
const resourceDetails = await primaryDb
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -157,17 +166,48 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
|||||||
.from(resources)
|
.from(resources)
|
||||||
.where(inArray(resources.resourceId, resourceIds));
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
// Create a map for quick lookup
|
for (const r of resourceDetails) {
|
||||||
const resourceMap = new Map(
|
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||||
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
niceId: siteResources.niceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
for (const r of siteResourceDetails) {
|
||||||
|
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich logs with resource details
|
// Enrich logs with resource details
|
||||||
return logs.map(log => ({
|
return logs.map(log => {
|
||||||
|
if (log.resourceId != null) {
|
||||||
|
const details = resourceMap.get(log.resourceId);
|
||||||
|
return {
|
||||||
...log,
|
...log,
|
||||||
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
|
resourceName: details?.name ?? null,
|
||||||
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
|
resourceNiceId: details?.niceId ?? null
|
||||||
}));
|
};
|
||||||
|
} else if (log.siteResourceId != null) {
|
||||||
|
const details = siteResourceMap.get(log.siteResourceId);
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
resourceId: log.siteResourceId,
|
||||||
|
resourceName: details?.name ?? null,
|
||||||
|
resourceNiceId: details?.niceId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...log, resourceName: null, resourceNiceId: null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countRequestQuery(data: Q) {
|
export function countRequestQuery(data: Q) {
|
||||||
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
|
|||||||
uniqueLocations,
|
uniqueLocations,
|
||||||
uniqueHosts,
|
uniqueHosts,
|
||||||
uniquePaths,
|
uniquePaths,
|
||||||
uniqueResources
|
uniqueResources,
|
||||||
|
uniqueSiteResources
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
primaryLogsDb
|
primaryLogsDb
|
||||||
.selectDistinct({ actor: requestAuditLog.actor })
|
.selectDistinct({ actor: requestAuditLog.actor })
|
||||||
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
|
|||||||
})
|
})
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
|
primaryLogsDb
|
||||||
|
.selectDistinct({
|
||||||
|
id: requestAuditLog.siteResourceId
|
||||||
|
})
|
||||||
|
.from(requestAuditLog)
|
||||||
|
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
|
||||||
.limit(DISTINCT_LIMIT + 1)
|
.limit(DISTINCT_LIMIT + 1)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
|
|||||||
.map(row => row.id)
|
.map(row => row.id)
|
||||||
.filter((id): id is number => id !== null);
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
|
const siteResourceIds = uniqueSiteResources
|
||||||
|
.map(row => row.id)
|
||||||
|
.filter((id): id is number => id !== null);
|
||||||
|
|
||||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||||
|
|
||||||
if (resourceIds.length > 0) {
|
if (resourceIds.length > 0) {
|
||||||
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
|
|||||||
.from(resources)
|
.from(resources)
|
||||||
.where(inArray(resources.resourceId, resourceIds));
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
resourcesWithNames = resourceDetails.map(r => ({
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...resourceDetails.map(r => ({
|
||||||
id: r.resourceId,
|
id: r.resourceId,
|
||||||
name: r.name
|
name: r.name
|
||||||
}));
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const siteResourceDetails = await primaryDb
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
name: siteResources.name
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||||
|
|
||||||
|
resourcesWithNames = [
|
||||||
|
...resourcesWithNames,
|
||||||
|
...siteResourceDetails.map(r => ({
|
||||||
|
id: r.siteResourceId,
|
||||||
|
name: r.name
|
||||||
|
}))
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
|
|||||||
actor: string | null;
|
actor: string | null;
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
resourceId: number | null;
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
resourceNiceId: string | null;
|
resourceNiceId: string | null;
|
||||||
resourceName: string | null;
|
resourceName: string | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -38,6 +39,7 @@ const auditLogBuffer: Array<{
|
|||||||
metadata: any;
|
metadata: any;
|
||||||
action: boolean;
|
action: boolean;
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
reason: number;
|
reason: number;
|
||||||
location?: string;
|
location?: string;
|
||||||
originalRequestURL: string;
|
originalRequestURL: string;
|
||||||
@@ -186,6 +188,7 @@ export async function logRequestAudit(
|
|||||||
action: boolean;
|
action: boolean;
|
||||||
reason: number;
|
reason: number;
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
siteResourceId?: number;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
user?: { username: string; userId: string };
|
user?: { username: string; userId: string };
|
||||||
@@ -262,6 +265,7 @@ export async function logRequestAudit(
|
|||||||
metadata: sanitizeString(metadata),
|
metadata: sanitizeString(metadata),
|
||||||
action: data.action,
|
action: data.action,
|
||||||
resourceId: data.resourceId,
|
resourceId: data.resourceId,
|
||||||
|
siteResourceId: data.siteResourceId,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
location: sanitizeString(data.location),
|
location: sanitizeString(data.location),
|
||||||
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
ExitNode,
|
ExitNode,
|
||||||
|
networks,
|
||||||
resources,
|
resources,
|
||||||
Site,
|
Site,
|
||||||
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
targets
|
targets
|
||||||
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
// Filter out any null values from peers that didn't have an olm
|
// Filter out any null values from peers that didn't have an olm
|
||||||
const validPeers = peers.filter((peer) => peer !== null);
|
const validPeers = peers.filter((peer) => peer !== null);
|
||||||
|
|
||||||
// Get all enabled site resources for this site
|
// Get all enabled site resources for this site by joining through siteNetworks and networks
|
||||||
const allSiteResources = await db
|
const allSiteResources = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(eq(siteResources.siteId, siteId));
|
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||||
|
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||||
|
.where(eq(siteNetworks.siteId, siteId))
|
||||||
|
.then((rows) => rows.map((r) => r.siteResources));
|
||||||
|
|
||||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
@@ -168,13 +173,13 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const resourceTarget = generateSubnetProxyTargetV2(
|
const resourceTargets = await generateSubnetProxyTargetV2(
|
||||||
resource,
|
resource,
|
||||||
resourceClients
|
resourceClients
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resourceTarget) {
|
if (resourceTargets) {
|
||||||
targetsToSend.push(resourceTarget);
|
targetsToSend.push(...resourceTargets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets";
|
|||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
const newt = client as Newt;
|
const newt = client as Newt;
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
exitNode
|
exitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
|
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
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;
|
||||||
|
};
|
||||||
@@ -2,11 +2,12 @@ export * from "./createNewt";
|
|||||||
export * from "./getNewtToken";
|
export * from "./getNewtToken";
|
||||||
export * from "./handleNewtRegisterMessage";
|
export * from "./handleNewtRegisterMessage";
|
||||||
export * from "./handleReceiveBandwidthMessage";
|
export * from "./handleReceiveBandwidthMessage";
|
||||||
export * from "./handleGetConfigMessage";
|
export * from "./handleNewtGetConfigMessage";
|
||||||
export * from "./handleSocketMessages";
|
export * from "./handleSocketMessages";
|
||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
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";
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
networks,
|
||||||
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.innerJoin(
|
||||||
|
networks,
|
||||||
|
eq(siteResources.networkId, networks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(networks.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.siteId, site.siteId),
|
eq(siteNetworks.siteId, site.siteId),
|
||||||
eq(
|
eq(
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
client.clientId
|
client.clientId
|
||||||
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
if (jitMode) {
|
if (jitMode) {
|
||||||
// Add site configuration to the array
|
// Add site configuration to the array
|
||||||
siteConfigurations.push({
|
siteConfigurations.push({
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
db,
|
db,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
Site,
|
Site,
|
||||||
siteResources
|
siteNetworks,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, Olm, sites } from "@server/db";
|
import { clients, Olm } from "@server/db";
|
||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake } from "./peers";
|
import { initPeerAddHandshake } from "./peers";
|
||||||
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
|
|
||||||
const { siteId, resourceId, chainId } = message.data;
|
const { siteId, resourceId, chainId } = message.data;
|
||||||
|
|
||||||
let site: Site | null = null;
|
const sendCancel = async () => {
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: { chainId }
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let sitesToProcess: Site[] = [];
|
||||||
|
|
||||||
if (siteId) {
|
if (siteId) {
|
||||||
// get the site
|
|
||||||
const [siteRes] = await db
|
const [siteRes] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.siteId, siteId))
|
.where(eq(sites.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (siteRes) {
|
if (siteRes) {
|
||||||
site = siteRes;
|
sitesToProcess = [siteRes];
|
||||||
}
|
}
|
||||||
}
|
} else if (resourceId) {
|
||||||
|
|
||||||
if (resourceId && !site) {
|
|
||||||
const resources = await db
|
const resources = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!resources || resources.length === 0) {
|
if (!resources || resources.length === 0) {
|
||||||
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
logger.error(
|
||||||
// cancel the request from the olm side to not keep doing this
|
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||||
await sendToClient(
|
);
|
||||||
olm.olmId,
|
await sendCancel();
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resources.length > 1) {
|
if (resources.length > 1) {
|
||||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||||
logger.error(
|
logger.error(
|
||||||
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -117,47 +120,61 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
|
|
||||||
if (currentResourceAssociationCaches.length === 0) {
|
if (currentResourceAssociationCaches.length === 0) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||||
);
|
);
|
||||||
// cancel the request from the olm side to not keep doing this
|
await sendCancel();
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteIdFromResource = resource.siteId;
|
if (!resource.networkId) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||||
|
);
|
||||||
|
await sendCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// get the site
|
// Get all sites associated with this resource's network via siteNetworks
|
||||||
const [siteRes] = await db
|
const siteRows = await db
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||||
|
|
||||||
|
if (!siteRows || siteRows.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
await sendCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full site objects for all network members
|
||||||
|
const foundSites = await Promise.all(
|
||||||
|
siteRows.map(async ({ siteId: sid }) => {
|
||||||
|
const [s] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(eq(sites.siteId, siteIdFromResource));
|
.where(eq(sites.siteId, sid))
|
||||||
if (!siteRes) {
|
.limit(1);
|
||||||
logger.error(
|
return s ?? null;
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sitesToProcess.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||||
|
);
|
||||||
|
await sendCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
site = siteRes;
|
let handshakeInitiated = false;
|
||||||
}
|
|
||||||
|
|
||||||
if (!site) {
|
for (const site of sitesToProcess) {
|
||||||
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
// Check if the client can access this site using the cache
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if the client can access this site using the cache
|
|
||||||
const currentSiteAssociationCaches = await db
|
const currentSiteAssociationCaches = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clientSitesAssociationsCache)
|
.from(clientSitesAssociationsCache)
|
||||||
@@ -169,46 +186,19 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (currentSiteAssociationCaches.length === 0) {
|
if (currentSiteAssociationCaches.length === 0) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
|
||||||
);
|
);
|
||||||
// cancel the request from the olm side to not keep doing this
|
continue;
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||||
);
|
);
|
||||||
// cancel the request from the olm side to not keep doing this
|
continue;
|
||||||
await sendToClient(
|
|
||||||
olm.olmId,
|
|
||||||
{
|
|
||||||
type: "olm/wg/peer/chain/cancel",
|
|
||||||
data: {
|
|
||||||
chainId
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: false }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the exit node from the side
|
|
||||||
const [exitNode] = await db
|
const [exitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(exitNodes)
|
.from(exitNodes)
|
||||||
@@ -216,15 +206,13 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||||
);
|
);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
// Trigger the peer add handshake — if the peer was already added this will be a no-op
|
||||||
// if it has already been added this will be a no-op
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
|
||||||
client.clientId,
|
client.clientId,
|
||||||
{
|
{
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
@@ -237,5 +225,15 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
|||||||
chainId
|
chainId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
handshakeInitiated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!handshakeInitiated) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||||
|
);
|
||||||
|
await sendCancel();
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -1,43 +1,25 @@
|
|||||||
import {
|
import {
|
||||||
Client,
|
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
db,
|
db,
|
||||||
ExitNode,
|
networks,
|
||||||
Org,
|
siteNetworks,
|
||||||
orgs,
|
|
||||||
roleClients,
|
|
||||||
roles,
|
|
||||||
siteResources,
|
siteResources,
|
||||||
Transaction,
|
|
||||||
userClients,
|
|
||||||
userOrgs,
|
|
||||||
users
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
exitNodes,
|
|
||||||
Olm,
|
Olm,
|
||||||
olms,
|
|
||||||
sites
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
|
||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
getNextAvailableClientSubnet
|
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { generateRemoteSubnets } from "@server/lib/ip";
|
import { generateRemoteSubnets } from "@server/lib/ip";
|
||||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import {
|
import {
|
||||||
addPeer as newtAddPeer,
|
addPeer as newtAddPeer,
|
||||||
deletePeer as newtDeletePeer
|
|
||||||
} from "@server/routers/newt/peers";
|
} from "@server/routers/newt/peers";
|
||||||
|
|
||||||
export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||||
@@ -153,14 +135,22 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.where(
|
.innerJoin(
|
||||||
|
networks,
|
||||||
|
eq(siteResources.networkId, networks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
and(
|
and(
|
||||||
eq(siteResources.siteId, site.siteId),
|
eq(networks.networkId, siteNetworks.networkId),
|
||||||
|
eq(siteNetworks.siteId, site.siteId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
eq(
|
eq(
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
client.clientId
|
client.clientId
|
||||||
)
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function getUserResources(
|
|||||||
niceId: string;
|
niceId: 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;
|
||||||
@@ -158,7 +158,7 @@ export async function getUserResources(
|
|||||||
niceId: siteResources.niceId,
|
niceId: siteResources.niceId,
|
||||||
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
|
||||||
@@ -242,7 +242,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,
|
||||||
@@ -291,7 +291,7 @@ export type GetUserResourcesResponse = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
type: 'site';
|
type: "site";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, Site, siteResources } from "@server/db";
|
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
||||||
import { newts, newtSessions, sites } from "@server/db";
|
import { newts, newtSessions, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -71,19 +71,24 @@ export async function deleteSite(
|
|||||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
}
|
}
|
||||||
} else if (site.type == "newt") {
|
} else if (site.type == "newt") {
|
||||||
// delete all of the site resources on this site
|
const networks = await trx
|
||||||
const siteResourcesOnSite = trx
|
.select({ networkId: siteNetworks.networkId })
|
||||||
.delete(siteResources)
|
.from(siteNetworks)
|
||||||
.where(eq(siteResources.siteId, siteId))
|
.where(eq(siteNetworks.siteId, siteId));
|
||||||
.returning();
|
|
||||||
|
|
||||||
// loop through them
|
// loop through them
|
||||||
for (const removedSiteResource of await siteResourcesOnSite) {
|
for (const network of await networks) {
|
||||||
|
const [siteResource] = await trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(eq(siteResources.networkId, network.networkId));
|
||||||
|
if (siteResource) {
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
removedSiteResource,
|
siteResource,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// get the newt on the site by querying the newt table for siteId
|
// get the newt on the site by querying the newt table for siteId
|
||||||
const [deletedNewt] = await trx
|
const [deletedNewt] = await trx
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
orgs,
|
orgs,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
|
siteNetworks,
|
||||||
|
networks,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
@@ -17,17 +19,18 @@ import {
|
|||||||
portRangeStringSchema
|
portRangeStringSchema
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
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 +39,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"]),
|
||||||
siteId: z.int(),
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
|
siteIds: z.array(z.int()),
|
||||||
// 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,12 +61,15 @@ const createSiteResourceSchema = z
|
|||||||
udpPortRangeString: portRangeStringSchema,
|
udpPortRangeString: portRangeStringSchema,
|
||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
authDaemonPort: z.int().positive().optional(),
|
authDaemonPort: z.int().positive().optional(),
|
||||||
authDaemonMode: z.enum(["site", "remote"]).optional()
|
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||||
|
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
|
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (data.mode === "host") {
|
||||||
|
if (data.mode == "host") {
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
const isValidIP = z
|
const isValidIP = z
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
@@ -72,6 +79,7 @@ const createSiteResourceSchema = z
|
|||||||
if (isValidIP) {
|
if (isValidIP) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||||
const domainRegex =
|
const domainRegex =
|
||||||
@@ -105,6 +113,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>;
|
||||||
@@ -159,13 +182,14 @@ export async function createSiteResource(
|
|||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteId,
|
siteIds,
|
||||||
mode,
|
mode,
|
||||||
// protocol,
|
scheme,
|
||||||
// proxyPort,
|
// proxyPort,
|
||||||
// destinationPort,
|
destinationPort,
|
||||||
destination,
|
destination,
|
||||||
enabled,
|
enabled,
|
||||||
|
ssl,
|
||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
@@ -174,18 +198,36 @@ export async function createSiteResource(
|
|||||||
udpPortRangeString,
|
udpPortRangeString,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode
|
authDaemonMode,
|
||||||
|
domainId,
|
||||||
|
subdomain
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (mode == "http") {
|
||||||
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||||
|
);
|
||||||
|
if (!hasHttpFeature) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
const [site] = await db
|
const sitesToAssign = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
if (sitesToAssign.length !== siteIds.length) {
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [org] = await db
|
const [org] = await db
|
||||||
@@ -226,29 +268,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,27 +343,49 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSiteResource: SiteResource | undefined;
|
let newSiteResource: SiteResource | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
const [network] = await trx
|
||||||
|
.insert(networks)
|
||||||
|
.values({
|
||||||
|
scope: "resource",
|
||||||
|
orgId: orgId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!network) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Failed to create network`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create the site resource
|
// Create the site resource
|
||||||
const insertValues: typeof siteResources.$inferInsert = {
|
const insertValues: typeof siteResources.$inferInsert = {
|
||||||
siteId,
|
|
||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode: mode as "host" | "cidr",
|
mode,
|
||||||
|
ssl,
|
||||||
|
networkId: network.networkId,
|
||||||
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)
|
||||||
@@ -317,6 +402,13 @@ export async function createSiteResource(
|
|||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: siteId,
|
||||||
|
networkId: network.networkId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -359,17 +451,22 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const siteToAssign of sitesToAssign) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, siteToAssign.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Newt not found for site ${siteToAssign.siteId}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
newSiteResource,
|
newSiteResource,
|
||||||
@@ -387,7 +484,7 @@ export async function createSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
|
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
|
|||||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const [newt] = await trx
|
// not sure why this is here...
|
||||||
.select()
|
// const [newt] = await trx
|
||||||
.from(newts)
|
// .select()
|
||||||
.where(eq(newts.siteId, removedSiteResource.siteId))
|
// .from(newts)
|
||||||
.limit(1);
|
// .where(eq(newts.siteId, removedSiteResource.siteId))
|
||||||
|
// .limit(1);
|
||||||
|
|
||||||
if (!newt) {
|
// if (!newt) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
// createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
removedSiteResource,
|
removedSiteResource,
|
||||||
|
|||||||
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
|
|||||||
.transform((val) => (val ? Number(val) : undefined))
|
.transform((val) => (val ? Number(val) : undefined))
|
||||||
.pipe(z.int().positive().optional())
|
.pipe(z.int().positive().optional())
|
||||||
.optional(),
|
.optional(),
|
||||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
|
||||||
niceId: z.string().optional(),
|
niceId: z.string().optional(),
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
async function query(
|
async function query(
|
||||||
siteResourceId?: number,
|
siteResourceId?: number,
|
||||||
siteId?: number,
|
|
||||||
niceId?: string,
|
niceId?: string,
|
||||||
orgId?: string
|
orgId?: string
|
||||||
) {
|
) {
|
||||||
if (siteResourceId && siteId && orgId) {
|
if (siteResourceId && orgId) {
|
||||||
const [siteResource] = await db
|
const [siteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
eq(siteResources.siteResourceId, siteResourceId),
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
eq(siteResources.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return siteResource;
|
return siteResource;
|
||||||
} else if (niceId && siteId && orgId) {
|
} else if (niceId && orgId) {
|
||||||
const [siteResource] = await db
|
const [siteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.niceId, niceId),
|
eq(siteResources.niceId, niceId),
|
||||||
eq(siteResources.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
eq(siteResources.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -84,7 +80,6 @@ registry.registerPath({
|
|||||||
request: {
|
request: {
|
||||||
params: z.object({
|
params: z.object({
|
||||||
niceId: z.string(),
|
niceId: z.string(),
|
||||||
siteId: z.number(),
|
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -107,10 +102,10 @@ export async function getSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
const { siteResourceId, niceId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
// Get the site resource
|
// Get the site resource
|
||||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
const siteResource = await query(siteResourceId, niceId, orgId);
|
||||||
|
|
||||||
if (!siteResource) {
|
if (!siteResource) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -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
|
||||||
@@ -73,22 +73,58 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
siteResources: (SiteResource & {
|
siteResources: (SiteResource & {
|
||||||
siteName: string;
|
siteOnlines: boolean[];
|
||||||
siteNiceId: string;
|
siteIds: number[];
|
||||||
siteAddress: string | null;
|
siteNames: string[];
|
||||||
|
siteNiceIds: string[];
|
||||||
|
siteAddresses: (string | null)[];
|
||||||
})[];
|
})[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an aggregation expression compatible with both SQLite and PostgreSQL.
|
||||||
|
* - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch
|
||||||
|
* - PostgreSQL: array_agg(col) → returns a native array
|
||||||
|
*/
|
||||||
|
function aggCol<T>(column: any) {
|
||||||
|
if (DB_TYPE === "sqlite") {
|
||||||
|
return sql<T>`json_group_array(${column})`;
|
||||||
|
}
|
||||||
|
return sql<T>`array_agg(${column})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For SQLite the aggregated columns come back as JSON strings; parse them into
|
||||||
|
* proper arrays. For PostgreSQL the driver already returns native arrays, so
|
||||||
|
* the row is returned unchanged.
|
||||||
|
*/
|
||||||
|
function transformSiteResourceRow(row: any) {
|
||||||
|
if (DB_TYPE !== "sqlite") {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
siteNames: JSON.parse(row.siteNames) as string[],
|
||||||
|
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
|
||||||
|
siteIds: JSON.parse(row.siteIds) as number[],
|
||||||
|
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
|
||||||
|
// SQLite stores booleans as 0/1 integers
|
||||||
|
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
|
||||||
|
(v) => v === 1
|
||||||
|
) as boolean[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function querySiteResourcesBase() {
|
function querySiteResourcesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
siteResourceId: siteResources.siteResourceId,
|
siteResourceId: siteResources.siteResourceId,
|
||||||
siteId: siteResources.siteId,
|
|
||||||
orgId: siteResources.orgId,
|
orgId: siteResources.orgId,
|
||||||
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,12 +136,24 @@ function querySiteResourcesBase() {
|
|||||||
disableIcmp: siteResources.disableIcmp,
|
disableIcmp: siteResources.disableIcmp,
|
||||||
authDaemonMode: siteResources.authDaemonMode,
|
authDaemonMode: siteResources.authDaemonMode,
|
||||||
authDaemonPort: siteResources.authDaemonPort,
|
authDaemonPort: siteResources.authDaemonPort,
|
||||||
siteName: sites.name,
|
subdomain: siteResources.subdomain,
|
||||||
siteNiceId: sites.niceId,
|
domainId: siteResources.domainId,
|
||||||
siteAddress: sites.address
|
fullDomain: siteResources.fullDomain,
|
||||||
|
networkId: siteResources.networkId,
|
||||||
|
defaultNetworkId: siteResources.defaultNetworkId,
|
||||||
|
siteNames: aggCol<string[]>(sites.name),
|
||||||
|
siteNiceIds: aggCol<string[]>(sites.niceId),
|
||||||
|
siteIds: aggCol<number[]>(sites.siteId),
|
||||||
|
siteAddresses: aggCol<(string | null)[]>(sites.address),
|
||||||
|
siteOnlines: aggCol<boolean[]>(sites.online)
|
||||||
})
|
})
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
|
.groupBy(siteResources.siteResourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -193,10 +241,12 @@ 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 [siteResourcesRaw, totalCount] = await Promise.all([
|
||||||
baseQuery
|
baseQuery
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(pageSize * (page - 1))
|
.offset(pageSize * (page - 1))
|
||||||
@@ -210,6 +260,8 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
countQuery
|
countQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||||
|
|
||||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
siteResources: siteResourcesList,
|
siteResources: siteResourcesList,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, networks, siteNetworks } from "@server/db";
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
import { siteResources, sites, SiteResource } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -108,13 +108,21 @@ export async function listSiteResources(
|
|||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get site resources
|
// Get site resources by joining networks to siteResources via siteNetworks
|
||||||
const siteResourcesList = await db
|
const siteResourcesList = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteNetworks)
|
||||||
|
.innerJoin(
|
||||||
|
networks,
|
||||||
|
eq(siteNetworks.networkId, networks.networkId)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.networkId, networks.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(siteResources.siteId, siteId),
|
eq(siteNetworks.siteId, siteId),
|
||||||
eq(siteResources.orgId, orgId)
|
eq(siteResources.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -128,6 +136,7 @@ export async function listSiteResources(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: { siteResources: siteResourcesList },
|
data: { siteResources: siteResourcesList },
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import {
|
import {
|
||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
@@ -7,13 +6,21 @@ import {
|
|||||||
orgs,
|
orgs,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
|
siteNetworks,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
|
networks,
|
||||||
Transaction,
|
Transaction,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { eq, and, ne, inArray } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
||||||
import {
|
import {
|
||||||
generateAliasConfig,
|
generateAliasConfig,
|
||||||
generateRemoteSubnets,
|
generateRemoteSubnets,
|
||||||
@@ -22,12 +29,8 @@ import {
|
|||||||
portRangeStringSchema
|
portRangeStringSchema
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { and, eq, ne } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -40,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
|
|||||||
const updateSiteResourceSchema = z
|
const updateSiteResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
siteId: z.int(),
|
siteIds: z.array(z.int()),
|
||||||
|
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
niceId: z
|
niceId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -51,10 +55,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 +76,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 +125,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>;
|
||||||
@@ -172,11 +196,14 @@ export async function updateSiteResource(
|
|||||||
const { siteResourceId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteId, // because it can change
|
siteIds, // because it can change
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
|
scheme,
|
||||||
destination,
|
destination,
|
||||||
|
destinationPort,
|
||||||
alias,
|
alias,
|
||||||
|
ssl,
|
||||||
enabled,
|
enabled,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
@@ -185,19 +212,11 @@ export async function updateSiteResource(
|
|||||||
udpPortRangeString,
|
udpPortRangeString,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
authDaemonPort,
|
authDaemonPort,
|
||||||
authDaemonMode
|
authDaemonMode,
|
||||||
|
domainId,
|
||||||
|
subdomain
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if site resource exists
|
// Check if site resource exists
|
||||||
const [existingSiteResource] = await db
|
const [existingSiteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -211,6 +230,21 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode == "http") {
|
||||||
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
|
existingSiteResource.orgId,
|
||||||
|
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||||
|
);
|
||||||
|
if (!hasHttpFeature) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isLicensedSshPam = await isLicensedOrSubscribed(
|
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||||
existingSiteResource.orgId,
|
existingSiteResource.orgId,
|
||||||
tierMatrix.sshPam
|
tierMatrix.sshPam
|
||||||
@@ -237,6 +271,23 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify the site exists and belongs to the org
|
||||||
|
const sitesToAssign = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(sites.siteId, siteIds),
|
||||||
|
eq(sites.orgId, existingSiteResource.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sitesToAssign.length !== siteIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Only check if destination is an IP address
|
// Only check if destination is an IP address
|
||||||
const isIp = z
|
const isIp = z
|
||||||
.union([z.ipv4(), z.ipv6()])
|
.union([z.ipv4(), z.ipv6()])
|
||||||
@@ -254,22 +305,60 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let existingSite = site;
|
let sitesChanged = false;
|
||||||
let siteChanged = false;
|
const existingSiteIds = existingSiteResource.networkId
|
||||||
if (existingSiteResource.siteId !== siteId) {
|
? await db
|
||||||
siteChanged = true;
|
|
||||||
// get the existing site
|
|
||||||
[existingSite] = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(siteNetworks)
|
||||||
.where(eq(sites.siteId, existingSiteResource.siteId))
|
.where(
|
||||||
.limit(1);
|
eq(siteNetworks.networkId, existingSiteResource.networkId)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (!existingSite) {
|
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
|
||||||
|
const newSiteIdSet = new Set(siteIds);
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingSiteIdSet.size !== newSiteIdSet.size ||
|
||||||
|
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
|
||||||
|
) {
|
||||||
|
sitesChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.CONFLICT,
|
||||||
"Existing site not found"
|
"Resource with that domain already exists"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,7 +391,7 @@ export async function updateSiteResource(
|
|||||||
let updatedSiteResource: SiteResource | undefined;
|
let updatedSiteResource: SiteResource | undefined;
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
|
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
|
||||||
if (siteChanged) {
|
if (sitesChanged) {
|
||||||
// delete the existing site resource
|
// delete the existing site resource
|
||||||
await trx
|
await trx
|
||||||
.delete(siteResources)
|
.delete(siteResources)
|
||||||
@@ -343,15 +432,20 @@ export async function updateSiteResource(
|
|||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
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(
|
||||||
@@ -372,6 +466,23 @@ export async function updateSiteResource(
|
|||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
// delete the site - site resources associations
|
||||||
|
await trx
|
||||||
|
.delete(siteNetworks)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteNetworks.networkId,
|
||||||
|
updatedSiteResource.networkId!
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: siteId,
|
||||||
|
networkId: updatedSiteResource.networkId!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [adminRole] = await trx
|
const [adminRole] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
@@ -447,14 +558,20 @@ export async function updateSiteResource(
|
|||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
.set({
|
.set({
|
||||||
name: name,
|
name: name,
|
||||||
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(
|
||||||
@@ -464,6 +581,23 @@ export async function updateSiteResource(
|
|||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
// delete the site - site resources associations
|
||||||
|
await trx
|
||||||
|
.delete(siteNetworks)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteNetworks.networkId,
|
||||||
|
updatedSiteResource.networkId!
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
await trx.insert(siteNetworks).values({
|
||||||
|
siteId: siteId,
|
||||||
|
networkId: updatedSiteResource.networkId!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.delete(clientSiteResources)
|
.delete(clientSiteResources)
|
||||||
.where(
|
.where(
|
||||||
@@ -533,14 +667,15 @@ export async function updateSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(`Updated site resource ${siteResourceId}`);
|
||||||
`Updated site resource ${siteResourceId} for site ${siteId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await handleMessagingForUpdatedSiteResource(
|
await handleMessagingForUpdatedSiteResource(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
updatedSiteResource,
|
updatedSiteResource,
|
||||||
{ siteId: site.siteId, orgId: site.orgId },
|
siteIds.map((siteId) => ({
|
||||||
|
siteId,
|
||||||
|
orgId: existingSiteResource.orgId
|
||||||
|
})),
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -567,7 +702,7 @@ export async function updateSiteResource(
|
|||||||
export async function handleMessagingForUpdatedSiteResource(
|
export async function handleMessagingForUpdatedSiteResource(
|
||||||
existingSiteResource: SiteResource | undefined,
|
existingSiteResource: SiteResource | undefined,
|
||||||
updatedSiteResource: SiteResource,
|
updatedSiteResource: SiteResource,
|
||||||
site: { siteId: number; orgId: string },
|
sites: { siteId: number; orgId: string }[],
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -589,9 +724,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 +743,13 @@ 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
|
||||||
|
) {
|
||||||
|
for (const site of sites) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(newts)
|
.from(newts)
|
||||||
@@ -617,12 +763,16 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only update targets on newt if destination changed
|
// Only update targets on newt if destination changed
|
||||||
if (destinationChanged || portRangesChanged) {
|
if (
|
||||||
const oldTarget = generateSubnetProxyTargetV2(
|
destinationChanged ||
|
||||||
|
portRangesChanged ||
|
||||||
|
destinationPortChanged
|
||||||
|
) {
|
||||||
|
const oldTargets = await generateSubnetProxyTargetV2(
|
||||||
existingSiteResource,
|
existingSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
);
|
);
|
||||||
const newTarget = generateSubnetProxyTargetV2(
|
const newTargets = await generateSubnetProxyTargetV2(
|
||||||
updatedSiteResource,
|
updatedSiteResource,
|
||||||
mergedAllClients
|
mergedAllClients
|
||||||
);
|
);
|
||||||
@@ -630,8 +780,8 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
await updateTargets(
|
await updateTargets(
|
||||||
newt.newtId,
|
newt.newtId,
|
||||||
{
|
{
|
||||||
oldTargets: oldTarget ? [oldTarget] : [],
|
oldTargets: oldTargets ? oldTargets : [],
|
||||||
newTargets: newTarget ? [newTarget] : []
|
newTargets: newTargets ? newTargets : []
|
||||||
},
|
},
|
||||||
newt.version
|
newt.version
|
||||||
);
|
);
|
||||||
@@ -651,13 +801,17 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
siteResources.siteResourceId
|
siteResources.siteResourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteNetworks.networkId, siteResources.networkId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
client.clientId
|
client.clientId
|
||||||
),
|
),
|
||||||
eq(siteResources.siteId, site.siteId),
|
eq(siteNetworks.siteId, site.siteId),
|
||||||
eq(
|
eq(
|
||||||
siteResources.destination,
|
siteResources.destination,
|
||||||
existingSiteResource.destination
|
existingSiteResource.destination
|
||||||
@@ -676,10 +830,11 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
olmJobs.push(
|
olmJobs.push(
|
||||||
updatePeerData(
|
updatePeerData(
|
||||||
client.clientId,
|
client.clientId,
|
||||||
updatedSiteResource.siteId,
|
site.siteId,
|
||||||
destinationChanged
|
destinationChanged
|
||||||
? {
|
? {
|
||||||
oldRemoteSubnets: !oldDestinationStillInUseByASite
|
oldRemoteSubnets:
|
||||||
|
!oldDestinationStillInUseByASite
|
||||||
? generateRemoteSubnets([
|
? generateRemoteSubnets([
|
||||||
existingSiteResource
|
existingSiteResource
|
||||||
])
|
])
|
||||||
@@ -706,3 +861,4 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
await Promise.all(olmJobs);
|
await Promise.all(olmJobs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { build } from "@server/build";
|
|||||||
import {
|
import {
|
||||||
handleNewtRegisterMessage,
|
handleNewtRegisterMessage,
|
||||||
handleReceiveBandwidthMessage,
|
handleReceiveBandwidthMessage,
|
||||||
handleGetConfigMessage,
|
handleNewtGetConfigMessage,
|
||||||
handleDockerStatusMessage,
|
handleDockerStatusMessage,
|
||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage,
|
handleNewtPingRequestMessage,
|
||||||
@@ -37,7 +37,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"newt/disconnecting": handleNewtDisconnectingMessage,
|
"newt/disconnecting": handleNewtDisconnectingMessage,
|
||||||
"newt/ping": handleNewtPingMessage,
|
"newt/ping": handleNewtPingMessage,
|
||||||
"newt/wg/register": handleNewtRegisterMessage,
|
"newt/wg/register": handleNewtRegisterMessage,
|
||||||
"newt/wg/get-config": handleGetConfigMessage,
|
"newt/wg/get-config": handleNewtGetConfigMessage,
|
||||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||||
"newt/socket/status": handleDockerStatusMessage,
|
"newt/socket/status": handleDockerStatusMessage,
|
||||||
"newt/socket/containers": handleDockerContainersMessage,
|
"newt/socket/containers": handleDockerContainersMessage,
|
||||||
|
|||||||
@@ -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"),
|
||||||
@@ -510,14 +512,14 @@ export default function GeneralPage() {
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
href={
|
||||||
|
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||||
|
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
|
||||||
|
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||||
|
}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Button
|
<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 +636,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") },
|
||||||
|
|||||||
@@ -60,23 +60,34 @@ export default async function ClientResourcesPage(
|
|||||||
id: siteResource.siteResourceId,
|
id: siteResource.siteResourceId,
|
||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
siteName: siteResource.siteName,
|
sites: siteResource.siteIds.map((siteId, idx) => ({
|
||||||
siteAddress: siteResource.siteAddress || null,
|
siteId,
|
||||||
mode: siteResource.mode || ("port" as any),
|
siteName: siteResource.siteNames[idx],
|
||||||
|
siteNiceId: siteResource.siteNiceIds[idx],
|
||||||
|
online: siteResource.siteOnlines[idx]
|
||||||
|
})),
|
||||||
|
mode: siteResource.mode,
|
||||||
|
scheme: siteResource.scheme,
|
||||||
|
ssl: siteResource.ssl,
|
||||||
|
siteNames: siteResource.siteNames,
|
||||||
|
siteAddresses: siteResource.siteAddresses || null,
|
||||||
// protocol: siteResource.protocol,
|
// protocol: siteResource.protocol,
|
||||||
// proxyPort: siteResource.proxyPort,
|
// proxyPort: siteResource.proxyPort,
|
||||||
siteId: siteResource.siteId,
|
siteIds: siteResource.siteIds,
|
||||||
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,
|
siteNiceIds: siteResource.siteNiceIds,
|
||||||
niceId: siteResource.niceId,
|
niceId: siteResource.niceId,
|
||||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -38,21 +39,32 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
|
|||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
export type InternalResourceSiteRow = {
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteNiceId: string;
|
||||||
|
online: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type InternalResourceRow = {
|
export type InternalResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
siteName: string;
|
sites: InternalResourceSiteRow[];
|
||||||
siteAddress: string | null;
|
siteNames: string[];
|
||||||
|
siteAddresses: (string | null)[];
|
||||||
|
siteIds: number[];
|
||||||
|
siteNiceIds: string[];
|
||||||
// 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;
|
|
||||||
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 +73,147 @@ export type InternalResourceRow = {
|
|||||||
disableIcmp: boolean;
|
disableIcmp: boolean;
|
||||||
authDaemonMode?: "site" | "remote" | null;
|
authDaemonMode?: "site" | "remote" | null;
|
||||||
authDaemonPort?: number | null;
|
authDaemonPort?: number | null;
|
||||||
|
subdomain?: string | null;
|
||||||
|
domainId?: string | null;
|
||||||
|
fullDomain?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveHttpHttpsDisplayPort(
|
||||||
|
mode: "http",
|
||||||
|
httpHttpsPort: number | null
|
||||||
|
): number {
|
||||||
|
if (httpHttpsPort != null) {
|
||||||
|
return httpHttpsPort;
|
||||||
|
}
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||||
|
const { mode, destination, httpHttpsPort, scheme } = row;
|
||||||
|
if (mode !== "http") {
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
|
||||||
|
const downstreamScheme = scheme ?? "http";
|
||||||
|
const hostPart =
|
||||||
|
destination.includes(":") && !destination.startsWith("[")
|
||||||
|
? `[${destination}]`
|
||||||
|
: destination;
|
||||||
|
return `${downstreamScheme}://${hostPart}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeUrlForLink(href: string): boolean {
|
||||||
|
try {
|
||||||
|
void new URL(href);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||||
|
|
||||||
|
function aggregateSitesStatus(
|
||||||
|
resourceSites: InternalResourceSiteRow[]
|
||||||
|
): AggregateSitesStatus {
|
||||||
|
if (resourceSites.length === 0) {
|
||||||
|
return "allOffline";
|
||||||
|
}
|
||||||
|
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
||||||
|
if (onlineCount === resourceSites.length) return "allOnline";
|
||||||
|
if (onlineCount > 0) return "partial";
|
||||||
|
return "allOffline";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case "allOnline":
|
||||||
|
return "bg-green-500";
|
||||||
|
case "partial":
|
||||||
|
return "bg-yellow-500";
|
||||||
|
case "allOffline":
|
||||||
|
default:
|
||||||
|
return "bg-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientResourceSitesStatusCell({
|
||||||
|
orgId,
|
||||||
|
resourceSites
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resourceSites: InternalResourceSiteRow[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
if (resourceSites.length === 0) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = aggregateSitesStatus(resourceSites);
|
||||||
|
const countLabel = t("multiSitesSelectorSitesCount", {
|
||||||
|
count: resourceSites.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
aggregateStatusDotClass(aggregate)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm tabular-nums">{countLabel}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-56">
|
||||||
|
{resourceSites.map((site) => {
|
||||||
|
const isOnline = site.online;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem key={site.siteId} asChild>
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||||
|
className="flex cursor-pointer items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 shrink-0 rounded-full",
|
||||||
|
isOnline
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-gray-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{site.siteName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 capitalize",
|
||||||
|
isOnline
|
||||||
|
? "text-green-600"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOnline ? t("online") : t("offline")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -97,8 +248,6 @@ export default function ClientResourcesTable({
|
|||||||
useState<InternalResourceRow | null>();
|
useState<InternalResourceRow | null>();
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
@@ -136,6 +285,60 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
|
||||||
|
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
||||||
|
|
||||||
|
if (!siteNames || siteNames.length === 0) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteNames.length === 1) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
|
||||||
|
>
|
||||||
|
<Button variant="outline">
|
||||||
|
{siteNames[0]}
|
||||||
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{siteNames.length} {t("sites")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
{siteNames.map((siteName, idx) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={siteNiceIds[idx]}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
||||||
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
{siteName}
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
@@ -185,20 +388,17 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "siteName",
|
id: "sites",
|
||||||
friendlyName: t("site"),
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
header: () => <span className="p-3">{t("site")}</span>,
|
friendlyName: t("sites"),
|
||||||
|
header: () => <span className="p-3">{t("sites")}</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<Link
|
<ClientResourceSitesStatusCell
|
||||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
orgId={resourceRow.orgId}
|
||||||
>
|
resourceSites={resourceRow.sites}
|
||||||
<Button variant="outline">
|
/>
|
||||||
{resourceRow.siteName}
|
|
||||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -215,6 +415,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 +431,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 +451,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,16 +469,27 @@ 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) {
|
||||||
|
return (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resourceRow.alias}
|
text={resourceRow.alias}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
displayText={resourceRow.alias}
|
displayText={resourceRow.alias}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<span>-</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (resourceRow.mode === "http") {
|
||||||
|
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={url}
|
||||||
|
isLink={isSafeUrlForLink(url)}
|
||||||
|
displayText={url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "aliasAddress",
|
accessorKey: "aliasAddress",
|
||||||
@@ -399,7 +619,7 @@ export default function ClientResourcesTable({
|
|||||||
onConfirm={async () =>
|
onConfirm={async () =>
|
||||||
deleteInternalResource(
|
deleteInternalResource(
|
||||||
selectedInternalResource!.id,
|
selectedInternalResource!.id,
|
||||||
selectedInternalResource!.siteId
|
selectedInternalResource!.siteIds[0]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
string={selectedInternalResource.name}
|
string={selectedInternalResource.name}
|
||||||
@@ -435,7 +655,6 @@ export default function ClientResourcesTable({
|
|||||||
setOpen={setIsEditDialogOpen}
|
setOpen={setIsEditDialogOpen}
|
||||||
resource={editingResource}
|
resource={editingResource}
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
sites={sites}
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
// Delay refresh to allow modal to close smoothly
|
// Delay refresh to allow modal to close smoothly
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -450,7 +669,6 @@ export default function ClientResourcesTable({
|
|||||||
open={isCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
setOpen={setIsCreateDialogOpen}
|
setOpen={setIsCreateDialogOpen}
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
sites={sites}
|
|
||||||
onSuccess={() => {
|
onSuccess={() => {
|
||||||
// Delay refresh to allow modal to close smoothly
|
// Delay refresh to allow modal to close smoothly
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -25,13 +24,10 @@ import {
|
|||||||
type InternalResourceFormValues
|
type InternalResourceFormValues
|
||||||
} from "./InternalResourceForm";
|
} from "./InternalResourceForm";
|
||||||
|
|
||||||
type Site = ListSitesResponse["sites"][0];
|
|
||||||
|
|
||||||
type CreateInternalResourceDialogProps = {
|
type CreateInternalResourceDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (val: boolean) => void;
|
setOpen: (val: boolean) => void;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
sites: Site[];
|
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,18 +35,21 @@ export default function CreateInternalResourceDialog({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
orgId,
|
orgId,
|
||||||
sites,
|
|
||||||
onSuccess
|
onSuccess
|
||||||
}: CreateInternalResourceDialogProps) {
|
}: CreateInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (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 +64,56 @@ export default function CreateInternalResourceDialog({
|
|||||||
`/org/${orgId}/site-resource`,
|
`/org/${orgId}/site-resource`,
|
||||||
{
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteId: data.siteId,
|
siteIds: data.siteIds,
|
||||||
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" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.httpHttpsPort ?? undefined,
|
||||||
|
domainId: data.httpConfigDomainId
|
||||||
|
? data.httpConfigDomainId
|
||||||
|
: undefined,
|
||||||
|
subdomain: data.httpConfigSubdomain
|
||||||
|
? data.httpConfigSubdomain
|
||||||
|
: undefined
|
||||||
|
}),
|
||||||
|
...(data.mode === "host" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" &&
|
||||||
|
data.authDaemonPort != null && {
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" || data.mode == "cidr") && {
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
disableIcmp: data.disableIcmp ?? false,
|
disableIcmp: data.disableIcmp ?? false
|
||||||
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
|
}),
|
||||||
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
|
roleIds: data.roles
|
||||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
? 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 +123,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,31 +138,39 @@ 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>
|
||||||
<InternalResourceForm
|
<InternalResourceForm
|
||||||
variant="create"
|
variant="create"
|
||||||
open={open}
|
open={open}
|
||||||
sites={sites}
|
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
formId="create-internal-resource-form"
|
formId="create-internal-resource-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||||
/>
|
/>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
{t("createInternalResourceDialogCancel")}
|
{t("createInternalResourceDialogCancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="create-internal-resource-form"
|
form="create-internal-resource-form"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isHttpModeDisabled}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("createInternalResourceDialogCreateResource")}
|
{t("createInternalResourceDialogCreateResource")}
|
||||||
|
|||||||
@@ -175,15 +175,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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { resourceQueries } from "@app/lib/queries";
|
import { resourceQueries } from "@app/lib/queries";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
@@ -27,14 +26,11 @@ import {
|
|||||||
isHostname
|
isHostname
|
||||||
} from "./InternalResourceForm";
|
} from "./InternalResourceForm";
|
||||||
|
|
||||||
type Site = ListSitesResponse["sites"][0];
|
|
||||||
|
|
||||||
type EditInternalResourceDialogProps = {
|
type EditInternalResourceDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (val: boolean) => void;
|
setOpen: (val: boolean) => void;
|
||||||
resource: InternalResourceData;
|
resource: InternalResourceData;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
sites: Site[];
|
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,18 +39,21 @@ export default function EditInternalResourceDialog({
|
|||||||
setOpen,
|
setOpen,
|
||||||
resource,
|
resource,
|
||||||
orgId,
|
orgId,
|
||||||
sites,
|
|
||||||
onSuccess
|
onSuccess
|
||||||
}: EditInternalResourceDialogProps) {
|
}: EditInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (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 +66,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,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
|
...(data.mode === "http" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.httpHttpsPort ?? null,
|
||||||
|
domainId: data.httpConfigDomainId
|
||||||
|
? data.httpConfigDomainId
|
||||||
|
: undefined,
|
||||||
|
subdomain: data.httpConfigSubdomain
|
||||||
|
? data.httpConfigSubdomain
|
||||||
|
: undefined
|
||||||
|
}),
|
||||||
|
...(data.mode === "host" && {
|
||||||
alias:
|
alias:
|
||||||
data.alias &&
|
data.alias &&
|
||||||
typeof data.alias === "string" &&
|
typeof data.alias === "string" &&
|
||||||
data.alias.trim()
|
data.alias.trim()
|
||||||
? data.alias
|
? data.alias
|
||||||
: null,
|
: null,
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
|
||||||
disableIcmp: data.disableIcmp ?? false,
|
|
||||||
...(data.authDaemonMode != null && {
|
...(data.authDaemonMode != null && {
|
||||||
authDaemonMode: data.authDaemonMode
|
authDaemonMode: data.authDaemonMode
|
||||||
}),
|
}),
|
||||||
...(data.authDaemonMode === "remote" && {
|
...(data.authDaemonMode === "remote" && {
|
||||||
authDaemonPort: data.authDaemonPort || null
|
authDaemonPort: data.authDaemonPort || null
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" || data.mode === "cidr") && {
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false
|
||||||
}),
|
}),
|
||||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||||
userIds: (data.users || []).map((u) => u.id),
|
userIds: (data.users || []).map((u) => u.id),
|
||||||
@@ -156,13 +170,13 @@ export default function EditInternalResourceDialog({
|
|||||||
variant="edit"
|
variant="edit"
|
||||||
open={open}
|
open={open}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
sites={sites}
|
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
siteResourceId={resource.id}
|
siteResourceId={resource.id}
|
||||||
formId="edit-internal-resource-form"
|
formId="edit-internal-resource-form"
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
startTransition(() => handleSubmit(values))
|
startTransition(() => handleSubmit(values))
|
||||||
}
|
}
|
||||||
|
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||||
/>
|
/>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
@@ -178,7 +192,7 @@ export default function EditInternalResourceDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="edit-internal-resource-form"
|
form="edit-internal-resource-form"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isHttpModeDisabled}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("editInternalResourceDialogSaveResource")}
|
{t("editInternalResourceDialogSaveResource")}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import {
|
||||||
|
OptionSelect,
|
||||||
|
type OptionSelectOption
|
||||||
|
} from "@app/components/OptionSelect";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
@@ -34,7 +38,6 @@ import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|||||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||||
@@ -42,9 +45,15 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
import {
|
||||||
|
MultiSitesSelector,
|
||||||
|
formatMultiSitesSelectorLabel
|
||||||
|
} from "./multi-site-selector";
|
||||||
|
import type { Selectedsite } from "./site-selector";
|
||||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||||
import { MachinesSelector } from "./machines-selector";
|
import { MachinesSelector } from "./machines-selector";
|
||||||
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
|
||||||
// --- Helpers (shared) ---
|
// --- Helpers (shared) ---
|
||||||
|
|
||||||
@@ -118,15 +127,15 @@ export const cleanForFQDN = (name: string): string =>
|
|||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
type Site = ListSitesResponse["sites"][0];
|
export type InternalResourceMode = "host" | "cidr" | "http";
|
||||||
|
|
||||||
export type InternalResourceData = {
|
export type InternalResourceData = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
siteName: string;
|
siteNames: string[];
|
||||||
mode: "host" | "cidr";
|
mode: InternalResourceMode;
|
||||||
siteId: number;
|
siteIds: number[];
|
||||||
niceId: string;
|
niceId: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
@@ -135,14 +144,30 @@ export type InternalResourceData = {
|
|||||||
disableIcmp?: boolean;
|
disableIcmp?: boolean;
|
||||||
authDaemonMode?: "site" | "remote" | null;
|
authDaemonMode?: "site" | "remote" | null;
|
||||||
authDaemonPort?: number | null;
|
authDaemonPort?: number | null;
|
||||||
|
httpHttpsPort?: number | null;
|
||||||
|
scheme?: "http" | "https" | null;
|
||||||
|
ssl?: boolean;
|
||||||
|
subdomain?: string | null;
|
||||||
|
domainId?: string | null;
|
||||||
|
fullDomain?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
||||||
|
|
||||||
|
function buildSelectedSitesForResource(
|
||||||
|
resource: InternalResourceData,
|
||||||
|
): Selectedsite[] {
|
||||||
|
return resource.siteIds.map((siteId, idx) => ({
|
||||||
|
name: resource.siteNames[idx] ?? "",
|
||||||
|
siteId,
|
||||||
|
type: "newt" as const
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export type InternalResourceFormValues = {
|
export type InternalResourceFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
siteId: number;
|
siteIds: number[];
|
||||||
mode: "host" | "cidr";
|
mode: InternalResourceMode;
|
||||||
destination: string;
|
destination: string;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
niceId?: string;
|
niceId?: string;
|
||||||
@@ -151,6 +176,12 @@ export type InternalResourceFormValues = {
|
|||||||
disableIcmp?: boolean;
|
disableIcmp?: boolean;
|
||||||
authDaemonMode?: "site" | "remote" | null;
|
authDaemonMode?: "site" | "remote" | null;
|
||||||
authDaemonPort?: number | null;
|
authDaemonPort?: number | null;
|
||||||
|
httpHttpsPort?: number | null;
|
||||||
|
scheme?: "http" | "https";
|
||||||
|
ssl?: boolean;
|
||||||
|
httpConfigSubdomain?: string | null;
|
||||||
|
httpConfigDomainId?: string | null;
|
||||||
|
httpConfigFullDomain?: string | null;
|
||||||
roles?: z.infer<typeof tagSchema>[];
|
roles?: z.infer<typeof tagSchema>[];
|
||||||
users?: z.infer<typeof tagSchema>[];
|
users?: z.infer<typeof tagSchema>[];
|
||||||
clients?: z.infer<typeof tagSchema>[];
|
clients?: z.infer<typeof tagSchema>[];
|
||||||
@@ -160,28 +191,29 @@ type InternalResourceFormProps = {
|
|||||||
variant: "create" | "edit";
|
variant: "create" | "edit";
|
||||||
resource?: InternalResourceData;
|
resource?: InternalResourceData;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
sites: Site[];
|
|
||||||
orgId: string;
|
orgId: string;
|
||||||
siteResourceId?: number;
|
siteResourceId?: number;
|
||||||
formId: string;
|
formId: string;
|
||||||
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
onSubmit: (values: InternalResourceFormValues) => void | Promise<void>;
|
||||||
|
onSubmitDisabledChange?: (disabled: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InternalResourceForm({
|
export function InternalResourceForm({
|
||||||
variant,
|
variant,
|
||||||
resource,
|
resource,
|
||||||
open,
|
open,
|
||||||
sites,
|
|
||||||
orgId,
|
orgId,
|
||||||
siteResourceId,
|
siteResourceId,
|
||||||
formId,
|
formId,
|
||||||
onSubmit
|
onSubmit,
|
||||||
|
onSubmitDisabledChange
|
||||||
}: InternalResourceFormProps) {
|
}: InternalResourceFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
|
||||||
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
|
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||||
|
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
|
||||||
|
|
||||||
const nameRequiredKey =
|
const nameRequiredKey =
|
||||||
variant === "create"
|
variant === "create"
|
||||||
@@ -211,6 +243,22 @@ export function InternalResourceForm({
|
|||||||
variant === "create"
|
variant === "create"
|
||||||
? "createInternalResourceDialogModeCidr"
|
? "createInternalResourceDialogModeCidr"
|
||||||
: "editInternalResourceDialogModeCidr";
|
: "editInternalResourceDialogModeCidr";
|
||||||
|
const modeHttpKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogModeHttp"
|
||||||
|
: "editInternalResourceDialogModeHttp";
|
||||||
|
const schemeLabelKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogScheme"
|
||||||
|
: "editInternalResourceDialogScheme";
|
||||||
|
const enableSslLabelKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogEnableSsl"
|
||||||
|
: "editInternalResourceDialogEnableSsl";
|
||||||
|
const enableSslDescriptionKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogEnableSslDescription"
|
||||||
|
: "editInternalResourceDialogEnableSslDescription";
|
||||||
const destinationLabelKey =
|
const destinationLabelKey =
|
||||||
variant === "create"
|
variant === "create"
|
||||||
? "createInternalResourceDialogDestination"
|
? "createInternalResourceDialogDestination"
|
||||||
@@ -223,14 +271,28 @@ export function InternalResourceForm({
|
|||||||
variant === "create"
|
variant === "create"
|
||||||
? "createInternalResourceDialogAlias"
|
? "createInternalResourceDialogAlias"
|
||||||
: "editInternalResourceDialogAlias";
|
: "editInternalResourceDialogAlias";
|
||||||
|
const httpHttpsPortLabelKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogModePort"
|
||||||
|
: "editInternalResourceDialogModePort";
|
||||||
|
const httpConfigurationTitleKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogHttpConfiguration"
|
||||||
|
: "editInternalResourceDialogHttpConfiguration";
|
||||||
|
const httpConfigurationDescriptionKey =
|
||||||
|
variant === "create"
|
||||||
|
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||||
|
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const siteIdsSchema = siteRequiredKey
|
||||||
|
? z.array(z.number().int().positive()).min(1, t(siteRequiredKey))
|
||||||
|
: z.array(z.number().int().positive()).min(1);
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
|
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
|
||||||
siteId: z
|
siteIds: siteIdsSchema,
|
||||||
.number()
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
.int()
|
|
||||||
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
|
|
||||||
mode: z.enum(["host", "cidr"]),
|
|
||||||
destination: z
|
destination: z
|
||||||
.string()
|
.string()
|
||||||
.min(
|
.min(
|
||||||
@@ -240,6 +302,18 @@ export function InternalResourceForm({
|
|||||||
: undefined
|
: undefined
|
||||||
),
|
),
|
||||||
alias: z.string().nullish(),
|
alias: z.string().nullish(),
|
||||||
|
httpHttpsPort: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
|
httpConfigSubdomain: z.string().nullish(),
|
||||||
|
httpConfigDomainId: z.string().nullish(),
|
||||||
|
httpConfigFullDomain: z.string().nullish(),
|
||||||
niceId: z
|
niceId: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
@@ -261,12 +335,31 @@ export function InternalResourceForm({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.mode !== "http") return;
|
||||||
|
if (!data.scheme) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceDownstreamSchemeRequired"),
|
||||||
|
path: ["scheme"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.httpHttpsPort == null ||
|
||||||
|
!Number.isFinite(data.httpHttpsPort) ||
|
||||||
|
data.httpHttpsPort < 1
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceHttpPortRequired"),
|
||||||
|
path: ["httpHttpsPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
const availableSites = sites.filter((s) => s.type === "newt");
|
|
||||||
|
|
||||||
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
const rolesQuery = useQuery(orgQueries.roles({ orgId }));
|
||||||
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
const usersQuery = useQuery(orgQueries.users({ orgId }));
|
||||||
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
const clientsQuery = useQuery(orgQueries.machineClients({ orgId }));
|
||||||
@@ -385,7 +478,7 @@ export function InternalResourceForm({
|
|||||||
variant === "edit" && resource
|
variant === "edit" && resource
|
||||||
? {
|
? {
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
siteId: resource.siteId,
|
siteIds: resource.siteIds,
|
||||||
mode: resource.mode ?? "host",
|
mode: resource.mode ?? "host",
|
||||||
destination: resource.destination ?? "",
|
destination: resource.destination ?? "",
|
||||||
alias: resource.alias ?? null,
|
alias: resource.alias ?? null,
|
||||||
@@ -394,6 +487,12 @@ export function InternalResourceForm({
|
|||||||
disableIcmp: resource.disableIcmp ?? false,
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
authDaemonMode: resource.authDaemonMode ?? "site",
|
authDaemonMode: resource.authDaemonMode ?? "site",
|
||||||
authDaemonPort: resource.authDaemonPort ?? null,
|
authDaemonPort: resource.authDaemonPort ?? null,
|
||||||
|
httpHttpsPort: resource.httpHttpsPort ?? null,
|
||||||
|
scheme: resource.scheme ?? "http",
|
||||||
|
ssl: resource.ssl ?? false,
|
||||||
|
httpConfigSubdomain: resource.subdomain ?? null,
|
||||||
|
httpConfigDomainId: resource.domainId ?? null,
|
||||||
|
httpConfigFullDomain: resource.fullDomain ?? null,
|
||||||
niceId: resource.niceId,
|
niceId: resource.niceId,
|
||||||
roles: [],
|
roles: [],
|
||||||
users: [],
|
users: [],
|
||||||
@@ -401,10 +500,16 @@ export function InternalResourceForm({
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: "",
|
name: "",
|
||||||
siteId: availableSites[0]?.siteId ?? 0,
|
siteIds: [],
|
||||||
mode: "host",
|
mode: "host",
|
||||||
destination: "",
|
destination: "",
|
||||||
alias: null,
|
alias: null,
|
||||||
|
httpHttpsPort: null,
|
||||||
|
scheme: "http",
|
||||||
|
ssl: true,
|
||||||
|
httpConfigSubdomain: null,
|
||||||
|
httpConfigDomainId: null,
|
||||||
|
httpConfigFullDomain: null,
|
||||||
tcpPortRangeString: "*",
|
tcpPortRangeString: "*",
|
||||||
udpPortRangeString: "*",
|
udpPortRangeString: "*",
|
||||||
disableIcmp: false,
|
disableIcmp: false,
|
||||||
@@ -415,8 +520,10 @@ export function InternalResourceForm({
|
|||||||
clients: []
|
clients: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selectedSite, setSelectedSite] = useState<Selectedsite>(
|
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>(() =>
|
||||||
availableSites[0]
|
variant === "edit" && resource
|
||||||
|
? buildSelectedSitesForResource(resource)
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
@@ -425,6 +532,10 @@ export function InternalResourceForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mode = form.watch("mode");
|
const mode = form.watch("mode");
|
||||||
|
const httpConfigSubdomain = form.watch("httpConfigSubdomain");
|
||||||
|
const httpConfigDomainId = form.watch("httpConfigDomainId");
|
||||||
|
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
|
||||||
|
const isHttpMode = mode === "http";
|
||||||
const authDaemonMode = form.watch("authDaemonMode") ?? "site";
|
const authDaemonMode = form.watch("authDaemonMode") ?? "site";
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
const previousResourceId = useRef<number | null>(null);
|
const previousResourceId = useRef<number | null>(null);
|
||||||
@@ -444,10 +555,16 @@ export function InternalResourceForm({
|
|||||||
if (variant === "create" && open) {
|
if (variant === "create" && open) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: "",
|
name: "",
|
||||||
siteId: availableSites[0]?.siteId ?? 0,
|
siteIds: [],
|
||||||
mode: "host",
|
mode: "host",
|
||||||
destination: "",
|
destination: "",
|
||||||
alias: null,
|
alias: null,
|
||||||
|
httpHttpsPort: null,
|
||||||
|
scheme: "http",
|
||||||
|
ssl: true,
|
||||||
|
httpConfigSubdomain: null,
|
||||||
|
httpConfigDomainId: null,
|
||||||
|
httpConfigFullDomain: null,
|
||||||
tcpPortRangeString: "*",
|
tcpPortRangeString: "*",
|
||||||
udpPortRangeString: "*",
|
udpPortRangeString: "*",
|
||||||
disableIcmp: false,
|
disableIcmp: false,
|
||||||
@@ -457,12 +574,13 @@ export function InternalResourceForm({
|
|||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
});
|
});
|
||||||
|
setSelectedSites([]);
|
||||||
setTcpPortMode("all");
|
setTcpPortMode("all");
|
||||||
setUdpPortMode("all");
|
setUdpPortMode("all");
|
||||||
setTcpCustomPorts("");
|
setTcpCustomPorts("");
|
||||||
setUdpCustomPorts("");
|
setUdpCustomPorts("");
|
||||||
}
|
}
|
||||||
}, [variant, open]);
|
}, [variant, open, form]);
|
||||||
|
|
||||||
// Reset when edit dialog opens / resource changes
|
// Reset when edit dialog opens / resource changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -471,10 +589,16 @@ export function InternalResourceForm({
|
|||||||
if (resourceChanged) {
|
if (resourceChanged) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
siteId: resource.siteId,
|
siteIds: resource.siteIds,
|
||||||
mode: resource.mode ?? "host",
|
mode: resource.mode ?? "host",
|
||||||
destination: resource.destination ?? "",
|
destination: resource.destination ?? "",
|
||||||
alias: resource.alias ?? null,
|
alias: resource.alias ?? null,
|
||||||
|
httpHttpsPort: resource.httpHttpsPort ?? null,
|
||||||
|
scheme: resource.scheme ?? "http",
|
||||||
|
ssl: resource.ssl ?? false,
|
||||||
|
httpConfigSubdomain: resource.subdomain ?? null,
|
||||||
|
httpConfigDomainId: resource.domainId ?? null,
|
||||||
|
httpConfigFullDomain: resource.fullDomain ?? null,
|
||||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||||
disableIcmp: resource.disableIcmp ?? false,
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
@@ -484,6 +608,9 @@ export function InternalResourceForm({
|
|||||||
users: [],
|
users: [],
|
||||||
clients: []
|
clients: []
|
||||||
});
|
});
|
||||||
|
setSelectedSites(
|
||||||
|
buildSelectedSitesForResource(resource)
|
||||||
|
);
|
||||||
setTcpPortMode(
|
setTcpPortMode(
|
||||||
getPortModeFromString(resource.tcpPortRangeString)
|
getPortModeFromString(resource.tcpPortRangeString)
|
||||||
);
|
);
|
||||||
@@ -537,12 +664,18 @@ export function InternalResourceForm({
|
|||||||
form
|
form
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
|
||||||
|
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit((values) => {
|
onSubmit={form.handleSubmit((values) => {
|
||||||
|
const siteIds = values.siteIds;
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...values,
|
...values,
|
||||||
|
siteIds,
|
||||||
clients: (values.clients ?? []).map((c) => ({
|
clients: (values.clients ?? []).map((c) => ({
|
||||||
id: c.clientId.toString(),
|
id: c.clientId.toString(),
|
||||||
text: c.name
|
text: c.name
|
||||||
@@ -581,51 +714,6 @@ export function InternalResourceForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="siteId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-col">
|
|
||||||
<FormLabel>{t("site")}</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!field.value &&
|
|
||||||
"text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? availableSites.find(
|
|
||||||
(s) =>
|
|
||||||
s.siteId ===
|
|
||||||
field.value
|
|
||||||
)?.name
|
|
||||||
: t("selectSite")}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0">
|
|
||||||
<SitesSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedSite={selectedSite}
|
|
||||||
filterTypes={["newt"]}
|
|
||||||
onSelectSite={(site) => {
|
|
||||||
setSelectedSite(site);
|
|
||||||
field.onChange(site.siteId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HorizontalTabs
|
<HorizontalTabs
|
||||||
@@ -641,7 +729,7 @@ export function InternalResourceForm({
|
|||||||
title: t("editInternalResourceDialogAccessPolicy"),
|
title: t("editInternalResourceDialogAccessPolicy"),
|
||||||
href: "#"
|
href: "#"
|
||||||
},
|
},
|
||||||
...(disableEnterpriseFeatures || mode === "cidr"
|
...(disableEnterpriseFeatures || mode !== "host"
|
||||||
? []
|
? []
|
||||||
: [{ title: t("sshAccess"), href: "#" }])
|
: [{ title: t("sshAccess"), href: "#" }])
|
||||||
]}
|
]}
|
||||||
@@ -660,46 +748,148 @@ export function InternalResourceForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="grid grid-cols-3 gap-4 items-start mb-4">
|
||||||
|
<div className="min-w-0 col-span-1">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="siteIds"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col">
|
||||||
|
<FormLabel>
|
||||||
|
{t("sites")}
|
||||||
|
</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4 items-start",
|
"w-full justify-between",
|
||||||
mode === "cidr"
|
selectedSites.length ===
|
||||||
? "grid-cols-4"
|
0 &&
|
||||||
: "grid-cols-12"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<span className="truncate text-left">
|
||||||
className={
|
{formatMultiSitesSelectorLabel(
|
||||||
mode === "cidr"
|
selectedSites,
|
||||||
? "col-span-1"
|
t
|
||||||
: "col-span-3"
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<MultiSitesSelector
|
||||||
|
orgId={orgId}
|
||||||
|
selectedSites={
|
||||||
|
selectedSites
|
||||||
}
|
}
|
||||||
>
|
filterTypes={[
|
||||||
|
"newt"
|
||||||
|
]}
|
||||||
|
onSelectionChange={(
|
||||||
|
sites
|
||||||
|
) => {
|
||||||
|
setSelectedSites(
|
||||||
|
sites
|
||||||
|
);
|
||||||
|
field.onChange(
|
||||||
|
sites.map(
|
||||||
|
(s) =>
|
||||||
|
s.siteId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 col-span-2">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="mode"
|
name="mode"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
|
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: "host",
|
||||||
|
label: t(modeHostKey)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cidr",
|
||||||
|
label: t(modeCidrKey)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "http",
|
||||||
|
label: t(modeHttpKey)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t(modeLabelKey)}
|
{t(modeLabelKey)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
<OptionSelect<InternalResourceMode>
|
||||||
|
options={modeOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
cols={3}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4 items-start",
|
||||||
|
mode === "cidr" && "grid-cols-1",
|
||||||
|
mode === "http" && "grid-cols-3",
|
||||||
|
mode === "host" && "grid-cols-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode === "http" && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="scheme"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(schemeLabelKey)}
|
||||||
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={
|
onValueChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
value={field.value}
|
value={
|
||||||
|
field.value ??
|
||||||
|
"http"
|
||||||
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="host">
|
<SelectItem value="http">
|
||||||
{t(modeHostKey)}
|
http
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="cidr">
|
<SelectItem value="https">
|
||||||
{t(modeCidrKey)}
|
https
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -708,12 +898,13 @@ export function InternalResourceForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className={cn(
|
||||||
mode === "cidr"
|
mode === "cidr" && "col-span-1",
|
||||||
? "col-span-3"
|
(mode === "http" || mode === "host") &&
|
||||||
: "col-span-5"
|
"min-w-0"
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -724,15 +915,19 @@ export function InternalResourceForm({
|
|||||||
{t(destinationLabelKey)}
|
{t(destinationLabelKey)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="w-full"
|
||||||
|
disabled={isHttpMode && httpSectionDisabled}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{mode !== "cidr" && (
|
{mode === "host" && (
|
||||||
<div className="col-span-4">
|
<div className="min-w-0">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="alias"
|
name="alias"
|
||||||
@@ -744,6 +939,7 @@ export function InternalResourceForm({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
|
className="w-full"
|
||||||
value={
|
value={
|
||||||
field.value ??
|
field.value ??
|
||||||
""
|
""
|
||||||
@@ -756,9 +952,150 @@ export function InternalResourceForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mode === "http" && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="httpHttpsPort"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
httpHttpsPortLabelKey
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={
|
||||||
|
field.value ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw =
|
||||||
|
e.target
|
||||||
|
.value;
|
||||||
|
if (
|
||||||
|
raw === ""
|
||||||
|
) {
|
||||||
|
field.onChange(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n =
|
||||||
|
Number(raw);
|
||||||
|
field.onChange(
|
||||||
|
Number.isFinite(
|
||||||
|
n
|
||||||
|
)
|
||||||
|
? n
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isHttpMode && (
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.httpPrivateResources} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isHttpMode ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="my-8">
|
||||||
|
<label className="font-medium block">
|
||||||
|
{t(httpConfigurationTitleKey)}
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(httpConfigurationDescriptionKey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={httpSectionDisabled ? "pointer-events-none opacity-50" : undefined}>
|
||||||
|
<DomainPicker
|
||||||
|
key={
|
||||||
|
variant === "edit" && siteResourceId
|
||||||
|
? `http-domain-${siteResourceId}`
|
||||||
|
: "http-domain-create"
|
||||||
|
}
|
||||||
|
orgId={orgId}
|
||||||
|
cols={2}
|
||||||
|
hideFreeDomain
|
||||||
|
defaultSubdomain={
|
||||||
|
httpConfigSubdomain ?? undefined
|
||||||
|
}
|
||||||
|
defaultDomainId={
|
||||||
|
httpConfigDomainId ?? undefined
|
||||||
|
}
|
||||||
|
defaultFullDomain={
|
||||||
|
httpConfigFullDomain ?? undefined
|
||||||
|
}
|
||||||
|
onDomainChange={(res) => {
|
||||||
|
if (res === null) {
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigSubdomain",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigDomainId",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigFullDomain",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigSubdomain",
|
||||||
|
res.subdomain ?? null
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigDomainId",
|
||||||
|
res.domainId
|
||||||
|
);
|
||||||
|
form.setValue(
|
||||||
|
"httpConfigFullDomain",
|
||||||
|
res.fullDomain
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ssl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="internal-resource-ssl"
|
||||||
|
label={t(enableSslLabelKey)}
|
||||||
|
description={t(
|
||||||
|
enableSslDescriptionKey
|
||||||
|
)}
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
disabled={httpSectionDisabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
<label className="font-medium block">
|
<label className="font-medium block">
|
||||||
@@ -806,7 +1143,11 @@ export function InternalResourceForm({
|
|||||||
value={tcpPortMode}
|
value={tcpPortMode}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
v: PortMode
|
v: PortMode
|
||||||
) => setTcpPortMode(v)}
|
) =>
|
||||||
|
setTcpPortMode(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-[110px]">
|
<SelectTrigger className="w-[110px]">
|
||||||
@@ -815,13 +1156,19 @@ export function InternalResourceForm({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
{t("allPorts")}
|
{t(
|
||||||
|
"allPorts"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="blocked">
|
<SelectItem value="blocked">
|
||||||
{t("blocked")}
|
{t(
|
||||||
|
"blocked"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="custom">
|
<SelectItem value="custom">
|
||||||
{t("custom")}
|
{t(
|
||||||
|
"custom"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -833,9 +1180,12 @@ export function InternalResourceForm({
|
|||||||
value={
|
value={
|
||||||
tcpCustomPorts
|
tcpCustomPorts
|
||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(
|
||||||
|
e
|
||||||
|
) =>
|
||||||
setTcpCustomPorts(
|
setTcpCustomPorts(
|
||||||
e.target
|
e
|
||||||
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -899,7 +1249,11 @@ export function InternalResourceForm({
|
|||||||
value={udpPortMode}
|
value={udpPortMode}
|
||||||
onValueChange={(
|
onValueChange={(
|
||||||
v: PortMode
|
v: PortMode
|
||||||
) => setUdpPortMode(v)}
|
) =>
|
||||||
|
setUdpPortMode(
|
||||||
|
v
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger className="w-[110px]">
|
<SelectTrigger className="w-[110px]">
|
||||||
@@ -908,13 +1262,19 @@ export function InternalResourceForm({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
{t("allPorts")}
|
{t(
|
||||||
|
"allPorts"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="blocked">
|
<SelectItem value="blocked">
|
||||||
{t("blocked")}
|
{t(
|
||||||
|
"blocked"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="custom">
|
<SelectItem value="custom">
|
||||||
{t("custom")}
|
{t(
|
||||||
|
"custom"
|
||||||
|
)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -926,9 +1286,12 @@ export function InternalResourceForm({
|
|||||||
value={
|
value={
|
||||||
udpCustomPorts
|
udpCustomPorts
|
||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(
|
||||||
|
e
|
||||||
|
) =>
|
||||||
setUdpCustomPorts(
|
setUdpCustomPorts(
|
||||||
e.target
|
e
|
||||||
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -972,7 +1335,9 @@ export function InternalResourceForm({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
{t("editInternalResourceDialogIcmp")}
|
{t(
|
||||||
|
"editInternalResourceDialogIcmp"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1015,6 +1380,7 @@ export function InternalResourceForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4 p-1">
|
<div className="space-y-4 mt-4 p-1">
|
||||||
@@ -1213,8 +1579,8 @@ export function InternalResourceForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SSH Access tab */}
|
{/* SSH Access tab (host mode only) */}
|
||||||
{!disableEnterpriseFeatures && mode !== "cidr" && (
|
{!disableEnterpriseFeatures && mode === "host" && (
|
||||||
<div className="space-y-4 mt-4 p-1">
|
<div className="space-y-4 mt-4 p-1">
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -353,9 +353,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -363,9 +363,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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ function drawInteractiveCountries(
|
|||||||
});
|
});
|
||||||
hoverPath
|
hoverPath
|
||||||
.datum(country)
|
.datum(country)
|
||||||
.attr("d", path(country) as string)
|
.attr("d", path(country as any) as string)
|
||||||
.style("display", null);
|
.style("display", null);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,14 +12,6 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ContainersSelector } from "./ContainersSelector";
|
import { ContainersSelector } from "./ContainersSelector";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from "./ui/command";
|
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||||
@@ -212,6 +204,12 @@ export function ResourceTargetAddressItem({
|
|||||||
proxyTarget.port === 0 ? "" : proxyTarget.port
|
proxyTarget.port === 0 ? "" : proxyTarget.port
|
||||||
}
|
}
|
||||||
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
|
className="w-18.75 px-2 border-none placeholder-gray-400 rounded-l-xs"
|
||||||
|
type="number"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (["e", "E", "+", "-", "."].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const value = parseInt(e.target.value, 10);
|
const value = parseInt(e.target.value, 10);
|
||||||
if (!isNaN(value) && value > 0) {
|
if (!isNaN(value) && value > 0) {
|
||||||
@@ -227,6 +225,7 @@ export function ResourceTargetAddressItem({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export const orgQueries = {
|
|||||||
queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const,
|
queryKey: ["ORG", orgId, "SITES", { query, perPage }] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
const sp = new URLSearchParams({
|
const sp = new URLSearchParams({
|
||||||
pageSize: perPage.toString()
|
pageSize: perPage.toString(),
|
||||||
|
status: "approved"
|
||||||
});
|
});
|
||||||
|
|
||||||
if (query?.trim()) {
|
if (query?.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user