Compare commits

..

14 Commits

Author SHA1 Message Date
Milo Schwartz e5398d441e Merge pull request #3425 from fosrl/dev
Dev
2026-07-09 21:33:17 -04:00
miloschwartz d38f9ac2bb update readme 2026-07-09 21:32:35 -04:00
Milo Schwartz d4138e2141 Merge pull request #3421 from fosrl/dev
1.20.0
2026-07-09 10:40:35 -04:00
Milo Schwartz 09d5d9082e Merge pull request #3413 from fosrl/dev
1.20.0
2026-07-08 15:30:03 -04:00
Owen Schwartz 5cb316f4e9 Merge pull request #3404 from fosrl/dev
Add ? to support undefined
2026-07-07 10:59:56 -04:00
Owen Schwartz 6b6c9cf4d8 Merge pull request #3403 from fosrl/dev
Move jit mode to a config param
2026-07-07 10:41:59 -04:00
Owen Schwartz 05617c63c0 Merge pull request #3402 from fosrl/dev
1.19.4-s.5
2026-07-07 10:21:57 -04:00
Owen Schwartz d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen Schwartz 87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen Schwartz 2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
v1rusnl ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl 1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
Owen Schwartz 49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen Schwartz 45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
42 changed files with 170 additions and 477 deletions
+39 -3
View File
@@ -41,7 +41,7 @@
</strong> </strong>
</p> </p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls. Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
## Installation ## Installation
@@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform. Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
* Lightweight user-space connector runs anywhere
* Punches through any firewall
* Doesn't require open ports or a public IP
* Strict network segmentation
* WireGuard-based
* Get alerts when a device or network resource goes down
<img src="public/screenshots/sites.png" alt="Sites" width="100%" /> <img src="public/screenshots/sites.png" alt="Sites" width="100%" />
### Browser-based reverse proxy access ### Browser-based reverse proxy access
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
* Expose a web panel anywhere
* Access via any web browser
* Single sign-on across all resources
* HTTPS resources
* Remote desktop in the browser with VNC and RDP
* In-browser SSH terminal with privileged access management (PAM)
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" /> <img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network. Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
* Peer-to-peer with intelligent NAT traversal
* Hosts/IPs and port ranges
* Network ranges/CIDRs
* Friendly DNS aliases for network addresses
* Privileged access management (PAM) with SSH resources
* Private HTTPS resources only accessible on the private network
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" /> <img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Give users and roles access to resources ### Give users and roles access to resources
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define. Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
* Bring your existing identity provider (IdP) or use Pangolin identities
* Sync users and roles from your IdP
* User- and role-based access control
* Full network audit and access logs
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" /> <img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
### Find and launch resources from a personalized home page
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
* Single place for admins and non-admins to see accessible resources
* Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
## Download Clients ## Download Clients
Download the Pangolin client for your platform: Download the Pangolin client for your platform:
+1 -1
View File
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
traefik: traefik:
image: traefik:v3.6 image: traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service
+1 -1
View File
@@ -50,7 +50,7 @@ services:
- 80:80{{end}} - 80:80{{end}}
traefik: traefik:
image: docker.io/traefik:v3.6 image: docker.io/traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

+2 -8
View File
@@ -200,10 +200,7 @@ export const resources = pgTable(
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123)
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [ (t) => [
index("idx_resources_fulldomain") index("idx_resources_fulldomain")
@@ -454,10 +451,7 @@ export const siteResources = pgTable(
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: varchar("subdomain"), subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"), fullDomain: varchar("fullDomain")
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
}, },
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)] (t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
); );
+2 -4
View File
@@ -209,8 +209,7 @@ export const resources = sqliteTable("resources", {
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123)
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {
@@ -448,8 +447,7 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null" onDelete: "set null"
}), }),
subdomain: text("subdomain"), subdomain: text("subdomain"),
fullDomain: text("fullDomain"), fullDomain: text("fullDomain")
status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const networks = sqliteTable("networks", { export const networks = sqliteTable("networks", {
+6
View File
@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource, rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations"; } from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
+10 -16
View File
@@ -26,6 +26,9 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing"; import { LimitId } from "../billing";
import { usageService } from "../billing/usageService"; import { usageService } from "../billing/usageService";
@@ -198,19 +201,17 @@ export async function updatePrivateResources(
} }
} }
let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) { if (siteId && allSites.length === 0) {
// only add if there are not provided sites // only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org // Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx const [siteSingle] = await trx
.select({ siteId: sites.siteId, status: sites.status }) .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);
if (siteSingle) { if (siteSingle) {
allSites.push(siteSingle); allSites.push(siteSingle);
} }
resourceStatusFromSite = siteSingle.status ?? "approved";
} }
if (allSites.length === 0) { if (allSites.length === 0) {
@@ -219,13 +220,6 @@ export async function updatePrivateResources(
); );
} }
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
if (existingResource) { if (existingResource) {
let domainInfo: let domainInfo:
| { subdomain: string | null; domainId: string } | { subdomain: string | null; domainId: string }
@@ -249,7 +243,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceEnabled, enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
disableIcmp: disableIcmp:
resourceData["disable-icmp"] || resourceData["disable-icmp"] ||
@@ -268,8 +263,7 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123, authDaemonPort: resourceData["auth-daemon"]?.port || 22123
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -502,7 +496,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme, scheme: resourceData.scheme,
destination: resourceData.destination, destination: resourceData.destination,
destinationPort: resourceData["destination-port"], destinationPort: resourceData["destination-port"],
enabled: resourceEnabled, enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress, aliasAddress: aliasAddress,
disableIcmp: disableIcmp:
@@ -522,8 +517,7 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough", pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode: authDaemonMode:
resourceData["auth-daemon"]?.mode || "native", resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123, authDaemonPort: resourceData["auth-daemon"]?.port || 22123
status: resourceStatusFromSite
}) })
.returning(); .returning();
+15 -37
View File
@@ -25,7 +25,6 @@ import {
rolePolicies, rolePolicies,
roleResources, roleResources,
roles, roles,
Site,
sites, sites,
Target, Target,
TargetHealthCheck, TargetHealthCheck,
@@ -75,40 +74,19 @@ export async function updatePublicResources(
)) { )) {
const targetsToUpdate: Target[] = []; const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = []; const healthchecksToUpdate: TargetHealthCheck[] = [];
let resource: Resource; let resource: Resource;
let resourceStatusFromSite: "approved" | "pending" = "approved";
let providedSite: Partial<Site> | undefined;
if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[providedSite] = await trx
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
resourceStatusFromSite = providedSite?.status ?? "approved";
}
async function createTarget( // reusable function to create a target async function createTarget( // reusable function to create a target
resourceId: number, resourceId: number,
targetData: TargetData targetData: TargetData
) { ) {
const targetSiteId = targetData.site; const targetSiteId = targetData.site;
let site: Partial<Site> | undefined; let site;
if (targetSiteId) { if (targetSiteId) {
// Look up site by niceId // Look up site by niceId
[site] = await trx [site] = await trx
.select({ .select({ siteId: sites.siteId, type: sites.type })
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites) .from(sites)
.where( .where(
and( and(
@@ -117,9 +95,15 @@ export async function updatePublicResources(
) )
) )
.limit(1); .limit(1);
} else if (siteId && providedSite) { } 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 = providedSite; [site] = await trx
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else { } else {
throw new Error(`Target site is required`); throw new Error(`Target site is required`);
} }
@@ -155,7 +139,7 @@ export async function updatePublicResources(
.insert(targets) .insert(targets)
.values({ .values({
resourceId: resourceId, resourceId: resourceId,
siteId: site.siteId!, siteId: site.siteId,
ip: targetData.hostname, ip: targetData.hostname,
mode: resourceData.mode as Target["mode"], mode: resourceData.mode as Target["mode"],
method: targetData.method, method: targetData.method,
@@ -188,7 +172,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck) .insert(targetHealthCheck)
.values({ .values({
name: `${targetData.hostname}:${targetData.port}`, name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId!, siteId: site.siteId,
targetId: newTarget.targetId, targetId: newTarget.targetId,
orgId: orgId, orgId: orgId,
hcEnabled: healthcheckData?.enabled || false, hcEnabled: healthcheckData?.enabled || false,
@@ -246,10 +230,7 @@ export async function updatePublicResources(
const resourceEnabled = const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null resourceData.enabled == undefined || resourceData.enabled == null
? true ? true
: resourceStatusFromSite === "pending" : resourceData.enabled;
? false
: resourceData.enabled;
const resourceSsl = const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null resourceData.ssl == undefined || resourceData.ssl == null
? true ? true
@@ -425,8 +406,7 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ?? ? (resourceData["proxy-protocol-version"] ??
1) 1)
: 1, : 1,
resourcePolicyId: sharedPolicy.resourcePolicyId, resourcePolicyId: sharedPolicy.resourcePolicyId
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -610,8 +590,7 @@ export async function updatePublicResources(
authDaemonPort: authDaemonPort:
resourceData["auth-daemon"]?.port || 22123, resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null, resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId, defaultResourcePolicyId: inlinePolicyId
status: resourceStatusFromSite
}) })
.where( .where(
eq( eq(
@@ -1152,7 +1131,6 @@ export async function updatePublicResources(
.values({ .values({
orgId, orgId,
niceId: resourceNiceId, niceId: resourceNiceId,
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource", name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode, mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes( proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
+1 -1
View File
@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(), destination: z.string().min(1).optional(),
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),
-9
View File
@@ -496,7 +496,6 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (!sr.enabled) return false;
if (!sr.destination) return false; if (!sr.destination) return false;
if (sr.mode === "cidr") { if (sr.mode === "cidr") {
@@ -531,7 +530,6 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources return allSiteResources
.filter( .filter(
(sr) => (sr) =>
sr.enabled &&
sr.aliasAddress && sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) || ((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http")) (sr.fullDomain && sr.mode == "http"))
@@ -664,13 +662,6 @@ export async function generateSubnetProxyTargetV2(
subnet: string | null; subnet: string | null;
}[] }[]
): Promise<SubnetProxyTargetV2[] | undefined> { ): Promise<SubnetProxyTargetV2[] | undefined> {
if (!siteResource.enabled) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
);
return;
}
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.`
+16 -30
View File
@@ -1561,19 +1561,9 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource.udpPortRangeString || updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !== existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp); updatedSiteResource.disableIcmp);
// Toggling enabled on/off doesn't change any of the fields above, but it
// does change whether targets/peer data should exist at all, so it needs
// to drive the same old->new diff machinery: going enabled->disabled
// diffs "real data" against "nothing" (a remove), and disabled->enabled
// diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/
// generateRemoteSubnets/generateAliasConfig already return nothing for a
// disabled resource, so no other changes are needed here.
const enabledChanged =
existingSiteResource &&
existingSiteResource.enabled !== updatedSiteResource.enabled;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}` `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
); );
// 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
@@ -1584,16 +1574,14 @@ export async function handleMessagingForUpdatedSiteResource(
fullDomainChanged || fullDomainChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
destinationPortChanged || destinationPortChanged
enabledChanged
) { ) {
const shouldUpdateTargets = const shouldUpdateTargets =
destinationChanged || destinationChanged ||
sslChanged || sslChanged ||
portRangesChanged || portRangesChanged ||
fullDomainChanged || fullDomainChanged ||
destinationPortChanged || destinationPortChanged;
enabledChanged;
logger.debug( logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
@@ -1669,22 +1657,20 @@ export async function handleMessagingForUpdatedSiteResource(
peerDataUpdateBatch.push({ peerDataUpdateBatch.push({
clientId: client.clientId, clientId: client.clientId,
siteId, siteId,
remoteSubnets: remoteSubnets: destinationChanged
destinationChanged || enabledChanged ? {
? { oldRemoteSubnets: !oldDestinationStillInUseBySite
oldRemoteSubnets: ? generateRemoteSubnets([
!oldDestinationStillInUseBySite existingSiteResource
? generateRemoteSubnets([ ])
existingSiteResource : [],
]) newRemoteSubnets: generateRemoteSubnets([
: [], updatedSiteResource
newRemoteSubnets: generateRemoteSubnets([ ])
updatedSiteResource }
]) : undefined,
}
: undefined,
aliases: aliases:
aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? { ? {
oldAliases: generateAliasConfig([ oldAliases: generateAliasConfig([
existingSiteResource existingSiteResource
@@ -157,8 +157,7 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, userId),
eq(resources.orgId, orgId), eq(resources.orgId, orgId)
eq(resources.status, "approved")
) )
), ),
userRoleIds.length > 0 userRoleIds.length > 0
@@ -172,8 +171,7 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(roleResources.roleId, userRoleIds), inArray(roleResources.roleId, userRoleIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId)
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
@@ -185,11 +183,7 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
) )
.where( .where(
and( and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
eq(userPolicies.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
), ),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
@@ -205,48 +199,21 @@ async function resolveAccessibleIdsUncached(
.where( .where(
and( and(
inArray(rolePolicies.roleId, userRoleIds), inArray(rolePolicies.roleId, userRoleIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId)
eq(resources.status, "approved")
) )
) )
: Promise.resolve([]), : Promise.resolve([]),
db db
.select({ siteResourceId: userSiteResources.siteResourceId }) .select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources) .from(userSiteResources)
.innerJoin( .where(eq(userSiteResources.userId, userId)),
siteResources,
eq(
userSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, userId),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
),
userRoleIds.length > 0 userRoleIds.length > 0
? db ? db
.select({ .select({
siteResourceId: roleSiteResources.siteResourceId siteResourceId: roleSiteResources.siteResourceId
}) })
.from(roleSiteResources) .from(roleSiteResources)
.innerJoin( .where(inArray(roleSiteResources.roleId, userRoleIds))
siteResources,
eq(
roleSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, userRoleIds),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
)
: Promise.resolve([]) : Promise.resolve([])
]); ]);
@@ -398,7 +365,6 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
textMatch textMatch
) )
); );
@@ -436,7 +402,6 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
textMatch textMatch
) )
); );
@@ -538,8 +503,7 @@ async function listSiteGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true)
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
publicConditions.push(searchPublic); publicConditions.push(searchPublic);
@@ -594,8 +558,7 @@ async function listSiteGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true)
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
siteConditions.push(searchSite); siteConditions.push(searchSite);
@@ -658,8 +621,7 @@ async function listSiteGroups(
const noSitePublicConditions = [ const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true)
eq(resources.status, "approved")
]; ];
if (searchPublic) { if (searchPublic) {
noSitePublicConditions.push(searchPublic); noSitePublicConditions.push(searchPublic);
@@ -693,8 +655,7 @@ async function listSiteGroups(
const noSiteSiteConditions = [ const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true)
eq(siteResources.status, "approved")
]; ];
if (searchSite) { if (searchSite) {
noSiteSiteConditions.push(searchSite); noSiteSiteConditions.push(searchSite);
@@ -785,8 +746,7 @@ async function listLabelGroups(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true)
eq(resources.status, "approved")
]; ];
const searchPublic = buildSearchConditionForPublic(query.query); const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) { if (searchPublic) {
@@ -850,8 +810,7 @@ async function listLabelGroups(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true)
eq(siteResources.status, "approved")
]; ];
const searchSite = buildSearchConditionForSiteResource(query.query); const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) { if (searchSite) {
@@ -1038,7 +997,6 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds), inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1130,7 +1088,6 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds), inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
siteIdFilter != null siteIdFilter != null
? eq(sites.siteId, siteIdFilter) ? eq(sites.siteId, siteIdFilter)
: undefined : undefined
@@ -1425,8 +1382,7 @@ async function collectAccessibleSites(
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true)
eq(resources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
publicConditions.push(siteNameSearch); publicConditions.push(siteNameSearch);
@@ -1466,8 +1422,7 @@ async function collectAccessibleSites(
const siteConditions = [ const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true)
eq(siteResources.status, "approved")
]; ];
if (siteNameSearch) { if (siteNameSearch) {
siteConditions.push(siteNameSearch); siteConditions.push(siteNameSearch);
@@ -1521,7 +1476,6 @@ async function collectAccessibleLabels(
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true), eq(resources.enabled, true),
eq(resources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
@@ -1557,7 +1511,6 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds), inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
eq(labels.orgId, orgId) eq(labels.orgId, orgId)
]; ];
if (labelNameSearch) { if (labelNameSearch) {
+1 -6
View File
@@ -148,12 +148,7 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources) .from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(eq(siteNetworks.siteId, siteId))
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.enabled, true)
)
)
.then((rows) => rows.map((r) => r.siteResources)); .then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
+2 -8
View File
@@ -16,7 +16,7 @@ import {
generateRemoteSubnets generateRemoteSubnets
} from "@server/lib/ip"; } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -70,13 +70,7 @@ export async function buildSiteConfigurationForOlmClient(
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId)) .innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where( .where(
and( eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.enabled, true)
)
); );
const siteResourcesBySiteId = new Map<number, SiteResource[]>(); const siteResourcesBySiteId = new Map<number, SiteResource[]>();
-14
View File
@@ -138,15 +138,6 @@ const listResourcesSchema = z.strictObject({
description: description:
"When set, only resources that have at least one target on this site are returned" "When set, only resources that have at least one target on this site are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -460,7 +451,6 @@ export async function listResources(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -670,10 +660,6 @@ export async function listResources(
} }
} }
if (typeof status !== "undefined") {
conditions.push(eq(resources.status, status));
}
if (siteId != null) { if (siteId != null) {
const resourcesWithSite = db const resourcesWithSite = db
.select({ resourceId: targets.resourceId }) .select({ resourceId: targets.resourceId })
@@ -45,19 +45,18 @@ function userResourceAliasesCacheKey(
page: number, page: number,
pageSize: number, pageSize: number,
includeLabels: boolean, includeLabels: boolean,
labelFilter: string[], labelFilter: string[]
status?: "pending" | "approved"
) { ) {
const labelsKey = const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`; return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
} }
const listUserResourceAliasesParamsSchema = z.strictObject({ const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listUserResourceAliasesQuerySchema = z.object({ const listUserResourceAliasesQuerySchema = z.strictObject({
pageSize: z.coerce pageSize: z.coerce
.number<string>() .number<string>()
.int() .int()
@@ -97,16 +96,7 @@ const listUserResourceAliasesQuerySchema = z.object({
type: "array", type: "array",
description: description:
"Filter by resource labels. A resource matches when it has any of the given labels (OR)." "Filter by resource labels. A resource matches when it has any of the given labels (OR)."
}), })
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
})
}); });
export type UserResourceAliasItem = { export type UserResourceAliasItem = {
@@ -140,8 +130,7 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labels: labelFilter, labels: labelFilter
status
} = parsedQuery.data; } = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse( const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -183,8 +172,7 @@ export async function listUserResourceAliases(
page, page,
pageSize, pageSize,
includeLabels, includeLabels,
labelFilter ?? [], labelFilter ?? []
status
); );
const cachedData: ListUserResourceAliasesResponse | undefined = const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey); await cache.get(cacheKey);
@@ -269,10 +257,6 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (typeof status !== "undefined") {
whereConditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
whereConditions.push( whereConditions.push(
inArray( inArray(
@@ -56,6 +56,7 @@ const createSiteResourceSchema = z
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(), destination: z.string().min(1).nullish(),
enabled: z.boolean().default(true),
alias: z alias: z
.string() .string()
.regex( .regex(
@@ -274,6 +275,7 @@ export async function createSiteResource(
scheme, scheme,
destinationPort, destinationPort,
destination, destination,
enabled,
ssl, ssl,
alias, alias,
userIds, userIds,
@@ -537,6 +539,7 @@ export async function createSiteResource(
destination: destination, // the ssh can be null destination: destination, // the ssh can be null
scheme, scheme,
destinationPort, destinationPort,
enabled,
alias: alias ? alias.trim() : null, alias: alias ? alias.trim() : null,
aliasAddress, aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
@@ -86,15 +86,6 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
description: description:
"When set, only site resources associated with this site (via network) are returned" "When set, only site resources associated with this site (via network) are returned"
}), }),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}),
labels: z labels: z
.preprocess((val) => { .preprocess((val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
@@ -292,7 +283,6 @@ export async function listAllSiteResourcesByOrg(
sort_by, sort_by,
order, order,
siteId, siteId,
status,
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
@@ -325,10 +315,6 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
@@ -47,15 +47,6 @@ const listSiteResourcesQuerySchema = z.strictObject({
enum: ["asc", "desc"], enum: ["asc", "desc"],
default: "asc", default: "asc",
description: "Sort order" description: "Sort order"
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
}) })
}); });
@@ -119,7 +110,7 @@ export async function listSiteResources(
} }
const { siteId, orgId } = parsedParams.data; const { siteId, orgId } = parsedParams.data;
const { limit, offset, sort_by, order, status } = parsedQuery.data; const { limit, offset, sort_by, order } = parsedQuery.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const site = await db const site = await db
@@ -133,15 +124,6 @@ export async function listSiteResources(
} }
// Get site resources by joining networks to siteResources via siteNetworks // Get site resources by joining networks to siteResources via siteNetworks
const conditions = [
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
];
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
const siteResourcesList = await db const siteResourcesList = await db
.select() .select()
.from(siteNetworks) .from(siteNetworks)
@@ -150,7 +132,12 @@ export async function listSiteResources(
siteResources, siteResources,
eq(siteResources.networkId, networks.networkId) eq(siteResources.networkId, networks.networkId)
) )
.where(and(...conditions)) .where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.orderBy( .orderBy(
sort_by sort_by
? order === "asc" ? order === "asc"
@@ -152,11 +152,6 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// this is a partial update; only enforce destination when the
// caller is actually changing mode or destination
if (data.mode === undefined && data.destination === undefined) {
return true;
}
// destination is only optional for ssh mode with native authDaemonMode // destination is only optional for ssh mode with native authDaemonMode
if (data.mode === "ssh" && data.authDaemonMode === "native") { if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true; return true;
@@ -416,10 +411,8 @@ export async function updateSiteResource(
: []; : [];
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
// undefined means "leave unchanged" (partial update); only nulled out let fullDomain: string | null = null;
// when the mode is explicitly being changed away from http let finalSubdomain: string | null = null;
let fullDomain: string | null | undefined = undefined;
let finalSubdomain: string | null | undefined = undefined;
if (domainId) { if (domainId) {
// Validate domain and construct full domain // Validate domain and construct full domain
const domainResult = await validateAndConstructDomain( const domainResult = await validateAndConstructDomain(
@@ -455,11 +448,6 @@ export async function updateSiteResource(
) )
); );
} }
} else if (mode !== undefined && mode !== "http") {
// mode is explicitly changing away from http, so the resource
// can no longer have a domain associated with it
fullDomain = null;
finalSubdomain = null;
} }
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
@@ -528,28 +516,15 @@ export async function updateSiteResource(
destination: destination, destination: destination,
destinationPort: destinationPort, destinationPort: destinationPort,
enabled: enabled, enabled: enabled,
alias: alias: alias ? alias.trim() : null,
alias !== undefined
? alias
? alias.trim()
: null
: mode !== undefined &&
mode !== "host" &&
mode !== "ssh"
? null
: undefined,
tcpPortRangeString: tcpPortRangeStringAdjusted, tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString: udpPortRangeString:
mode == "http" || mode == "ssh" mode == "http" || mode == "ssh"
? "" ? ""
: udpPortRangeString, : udpPortRangeString,
disableIcmp: disableIcmp:
mode !== undefined disableIcmp ||
? disableIcmp || (mode == "http" || mode == "ssh" ? true : false),
(mode == "http" || mode == "ssh"
? true
: false)
: disableIcmp,
domainId, domainId,
subdomain: finalSubdomain, subdomain: finalSubdomain,
fullDomain, fullDomain,
@@ -23,7 +23,7 @@ import {
import { ChevronsUpDown } from "lucide-react"; import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form"; import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp"; import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = { type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>; control: Control<T>;
@@ -9,10 +9,10 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields"; import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm"; import { inferSshPamMode } from "@app/lib/privateResourceForm";
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
import { import {
FormControl, FormControl,
FormField, FormField,
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react"; import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields"; import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() { export default function PrivateResourceAccessPage() {
const t = useTranslations(); const t = useTranslations();
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields"; import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils"; import { asAnyControl, asAnySetValue } from "../../formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceCidrPage() { export default function PrivateResourceCidrPage() {
const t = useTranslations(); const t = useTranslations();
@@ -16,21 +16,19 @@ import { Button } from "@app/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react"; import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceGeneralPage() { export default function PrivateResourceGeneralPage() {
const t = useTranslations(); const t = useTranslations();
@@ -43,8 +41,7 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: siteResource.name, name: siteResource.name,
niceId: siteResource.niceId, niceId: siteResource.niceId
enabled: siteResource.enabled
} }
}); });
@@ -55,8 +52,7 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues(); const data = form.getValues();
await save({ await save({
name: data.name, name: data.name,
niceId: data.niceId, niceId: data.niceId
enabled: data.enabled
}); });
}, null); }, null);
@@ -80,42 +76,6 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form" id="private-resource-general-form"
> >
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
siteResource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
control={form.control} control={form.control}
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields"; import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "@app/lib/formControlUtils"; } from "../../formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHostPage() { export default function PrivateResourceHostPage() {
const t = useTranslations(); const t = useTranslations();
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "@app/lib/formControlUtils"; } from "../../formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHttpPage() { export default function PrivateResourceHttpPage() {
const t = useTranslations(); const t = useTranslations();
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react"; import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
import type { Selectedsite } from "@app/components/site-selector"; import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { import {
asAnyControl, asAnyControl,
asAnySetValue, asAnySetValue,
asAnyWatch asAnyWatch
} from "@app/lib/formControlUtils"; } from "../../formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; import { useSaveSiteResource } from "../../useSaveSiteResource";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() { export default function PrivateResourceSshPage() {
const t = useTranslations(); const t = useTranslations();
@@ -50,20 +50,16 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields"; import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges"; import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
import { import {
PrivateResourceAliasField, PrivateResourceAliasField,
PrivateResourceCidrDestinationField, PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields PrivateResourceHostDestinationFields
} from "@app/components/PrivateResourceDestinationFields"; } from "../PrivateResourceDestinationFields";
import { import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
export default function CreatePrivateResourcePage() { export default function CreatePrivateResourcePage() {
const params = useParams(); const params = useParams();
@@ -27,7 +27,6 @@ export default async function ClientResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
@@ -38,7 +38,6 @@ export default async function ProxyResourcesPage(
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = { let pagination: ListResourcesResponse["pagination"] = {
+1 -108
View File
@@ -23,7 +23,6 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -37,9 +36,7 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table"; import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import { import {
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight, ArrowRight,
@@ -52,17 +49,8 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { startTransition, useMemo, useState, useTransition } from "react";
startTransition,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
@@ -126,11 +114,6 @@ function isSafeUrlForLink(href: string): boolean {
} }
} }
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
@@ -191,30 +174,6 @@ export default function PrivateResourcesTable({
}); });
}; };
async function toggleInternalResourceEnabled(
val: boolean,
resourceId: number
) {
try {
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
`site-resource/${resourceId}`,
{
enabled: val
}
);
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
}
}
const deleteInternalResource = async ( const deleteInternalResource = async (
resourceId: number, resourceId: number,
siteId: number siteId: number
@@ -470,36 +429,6 @@ export default function PrivateResourcesTable({
); );
} }
}, },
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<InternalResourceEnabledForm
resource={row.original}
onToggleInternalResourceEnabled={
toggleInternalResourceEnabled
}
/>
)
},
{ {
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
@@ -714,39 +643,3 @@ function ClientResourceLabelCell({
/> />
); );
} }
type InternalResourceEnabledFormProps = {
resource: InternalResourceRow;
onToggleInternalResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function InternalResourceEnabledForm({
resource,
onToggleInternalResourceEnabled
}: InternalResourceEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
resource.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleInternalResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== resource.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+2 -2
View File
@@ -165,6 +165,7 @@ export function buildCreateSiteResourcePayload(
siteIds: data.siteIds, siteIds: data.siteIds,
mode: data.mode, mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined), destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
...(data.mode === "http" && { ...(data.mode === "http" && {
scheme: data.scheme, scheme: data.scheme,
ssl: data.ssl ?? false, ssl: data.ssl ?? false,
@@ -341,8 +342,7 @@ export function createGeneralFormSchema(t: TranslateFn) {
.string() .string()
.min(1) .min(1)
.max(255) .max(255)
.regex(/^[a-zA-Z0-9-]+$/), .regex(/^[a-zA-Z0-9-]+$/)
enabled: z.boolean()
}); });
} }