diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 84bd6de07..770e891fb 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -346,7 +346,7 @@ export const siteResources = pgTable("siteResources", { niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), - mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" + mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().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 destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index fecfa9e04..c36719130 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -380,7 +380,7 @@ export const siteResources = sqliteTable("siteResources", { niceId: text("niceId").notNull(), name: text("name").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), - mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" + mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().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 destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e56de628a..ee7bc5ad1 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -44,7 +44,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), - mode: z.enum(["host", "cidr", "http"]), + mode: z.enum(["host", "cidr", "http", "ssh"]), ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()).optional(), @@ -75,7 +75,7 @@ const createSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host") { + if (data.mode === "host" || data.mode === "ssh") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z // .union([z.ipv4(), z.ipv6()]) @@ -117,13 +117,24 @@ const createSiteResourceSchema = z ) .refine( (data) => { - if (data.mode !== "http") return true; - return ( - data.scheme !== undefined && - data.destinationPort !== undefined && - data.destinationPort >= 1 && - data.destinationPort <= 65535 - ); + if (data.mode === "http") { + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + } else if (data.mode === "ssh") { + // just check the destinationPort + return ( + data.destinationPort === undefined || + (data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535) + ); + } }, { message: @@ -391,6 +402,15 @@ export async function createSiteResource( ); } + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } + // Create the site resource const insertValues: typeof siteResources.$inferInsert = { niceId: updatedNiceId!, @@ -405,10 +425,12 @@ export async function createSiteResource( enabled, alias: alias ? alias.trim() : null, aliasAddress, - tcpPortRangeString: - mode == "http" ? "443,80" : tcpPortRangeString, - udpPortRangeString: mode == "http" ? "" : udpPortRangeString, - disableIcmp: disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" ? "" : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false domainId, subdomain: finalSubdomain, fullDomain diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 332b395b0..4f827d904 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -56,7 +56,7 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http"]).optional(), + mode: z.enum(["host", "cidr", "http", "ssh"]).optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), // proxyPort: z.int().positive().nullish(), @@ -85,7 +85,10 @@ const updateSiteResourceSchema = z .strict() .refine( (data) => { - if (data.mode === "host" && data.destination) { + if ( + (data.mode === "host" || data.mode == "ssh") && + data.destination + ) { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere @@ -126,15 +129,24 @@ const updateSiteResourceSchema = z ) .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 - ); + if (data.mode === "http") { + return ( + data.scheme !== undefined && + data.scheme !== null && + data.destinationPort !== undefined && + data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535 + ); + } else if (data.mode === "ssh") { + // just check the destinationPort + return ( + data.destinationPort === undefined || + (data.destinationPort !== null && + data.destinationPort >= 1 && + data.destinationPort <= 65535) + ); + } }, { message: @@ -446,6 +458,16 @@ export async function updateSiteResource( }) } : {}; + + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } + [updatedSiteResource] = await trx .update(siteResources) .set({ @@ -458,12 +480,14 @@ export async function updateSiteResource( destinationPort, enabled, alias: alias ? alias.trim() : null, - tcpPortRangeString: - mode == "http" ? "443,80" : tcpPortRangeString, + tcpPortRangeString: tcpPortRangeStringAdjusted, udpPortRangeString: - mode == "http" ? "" : udpPortRangeString, + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, disableIcmp: - disableIcmp || (mode == "http" ? true : false), // default to true for http resources, otherwise false + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false domainId, subdomain: finalSubdomain, fullDomain, diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 295541e8c..0400d63a5 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -120,7 +120,7 @@ export default async function ClientResourcesPage( // proxyPort: siteResource.proxyPort, siteIds: siteResource.siteIds, destination: siteResource.destination, - httpHttpsPort: siteResource.destinationPort ?? null, + destinationPort: siteResource.destinationPort ?? null, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, siteNiceIds: siteResource.siteNiceIds, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 32e00be50..a4232e98e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -83,7 +83,7 @@ export type InternalResourceRow = { // protocol: string | null; // proxyPort: number | null; destination: string; - httpHttpsPort: number | null; + destinationPort: number | null; alias: string | null; aliasAddress: string | null; niceId: string; @@ -107,7 +107,7 @@ function formatDestinationDisplay(row: InternalResourceRow): string { return formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, - httpHttpsPort: row.httpHttpsPort, + destinationPort: row.destinationPort, scheme: row.scheme }); } diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 58eac9b92..6e6f1abf4 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -76,7 +76,7 @@ export default function CreateInternalResourceDialog({ ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, - destinationPort: data.httpHttpsPort ?? undefined, + destinationPort: data.destinationPort ?? undefined, domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 11d31ba13..038a163db 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -78,7 +78,7 @@ export default function EditInternalResourceDialog({ ...(data.mode === "http" && { scheme: data.scheme, ssl: data.ssl ?? false, - destinationPort: data.httpHttpsPort ?? null, + destinationPort: data.destinationPort ?? null, domainId: data.httpConfigDomainId ? data.httpConfigDomainId : undefined, diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index f733df099..41008a2e8 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -54,6 +54,7 @@ import { MultiSitesSelector, formatMultiSitesSelectorLabel } from "./multi-site-selector"; +import { SitesSelector } from "./site-selector"; import type { Selectedsite } from "./site-selector"; import { MachinesSelector } from "./machines-selector"; @@ -154,7 +155,7 @@ export type InternalResourceData = { authDaemonMode?: "site" | "remote" | "native" | null; authDaemonPort?: number | null; pamMode?: "passthrough" | "push" | null; - httpHttpsPort?: number | null; + destinationPort?: number | null; scheme?: "http" | "https" | null; ssl?: boolean; subdomain?: string | null; @@ -187,7 +188,7 @@ export type InternalResourceFormValues = { authDaemonMode?: "site" | "remote" | "native" | null; authDaemonPort?: number | null; pamMode?: "passthrough" | "push" | null; - httpHttpsPort?: number | null; + destinationPort?: number | null; scheme?: "http" | "https"; ssl?: boolean; httpConfigSubdomain?: string | null; @@ -286,7 +287,7 @@ export function InternalResourceForm({ variant === "create" ? "createInternalResourceDialogAlias" : "editInternalResourceDialogAlias"; - const httpHttpsPortLabelKey = + const destinationPortLabelKey = variant === "create" ? "createInternalResourceDialogModePort" : "editInternalResourceDialogModePort"; @@ -308,16 +309,9 @@ export function InternalResourceForm({ name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), siteIds: siteIdsSchema, mode: z.enum(["host", "cidr", "http", "ssh"]), - destination: z - .string() - .min( - 1, - destinationRequiredKey - ? { message: t(destinationRequiredKey) } - : undefined - ), + destination: z.string(), alias: z.string().nullish(), - httpHttpsPort: z + destinationPort: z .number() .int() .min(1) @@ -356,6 +350,20 @@ export function InternalResourceForm({ .optional() }) .superRefine((data, ctx) => { + const isNativeSsh = + data.mode === "ssh" && data.authDaemonMode === "native"; + if ( + !isNativeSsh && + (!data.destination || data.destination.length < 1) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: destinationRequiredKey + ? t(destinationRequiredKey) + : "Destination is required", + path: ["destination"] + }); + } if (data.mode !== "http") return; if (!data.scheme) { ctx.addIssue({ @@ -365,14 +373,15 @@ export function InternalResourceForm({ }); } if ( - data.httpHttpsPort == null || - !Number.isFinite(data.httpHttpsPort) || - data.httpHttpsPort < 1 + !isNativeSsh && + (data.destinationPort == null || + !Number.isFinite(data.destinationPort) || + data.destinationPort < 1) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("internalResourceHttpPortRequired"), - path: ["httpHttpsPort"] + path: ["destinationPort"] }); } }); @@ -523,7 +532,7 @@ export function InternalResourceForm({ : (resource.authDaemonMode ?? "site"), authDaemonPort: resource.authDaemonPort ?? null, pamMode: resource.pamMode ?? "passthrough", - httpHttpsPort: resource.httpHttpsPort ?? null, + destinationPort: resource.destinationPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, httpConfigSubdomain: resource.subdomain ?? null, @@ -540,7 +549,7 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, - httpHttpsPort: null, + destinationPort: null, scheme: "http", ssl: true, httpConfigSubdomain: null, @@ -605,7 +614,7 @@ export function InternalResourceForm({ mode: "host", destination: "", alias: null, - httpHttpsPort: null, + destinationPort: null, scheme: "http", ssl: true, httpConfigSubdomain: null, @@ -641,7 +650,7 @@ export function InternalResourceForm({ mode: resource.mode ?? "host", destination: resource.destination ?? "", alias: resource.alias ?? null, - httpHttpsPort: resource.httpHttpsPort ?? null, + destinationPort: resource.destinationPort ?? null, scheme: resource.scheme ?? "http", ssl: resource.ssl ?? false, httpConfigSubdomain: resource.subdomain ?? null, @@ -812,56 +821,120 @@ export function InternalResourceForm({ {t("sites")} - - - - - - - - { - setSelectedSites( + > + + {selectedSites[0] + ?.name ?? + t( + "selectSite" + )} + + + + + + + { + setSelectedSites( + [ + site + ] + ); + field.onChange( + [ + site.siteId + ] + ); + }} + /> + + + ) : ( + + + + + + + + - s.siteId - ) - ); - }} - /> - - + ) => { + setSelectedSites( + sites + ); + field.onChange( + sites.map( + ( + s + ) => + s.siteId + ) + ); + }} + /> + + + )} )} @@ -950,8 +1023,10 @@ export function InternalResourceForm({ "grid gap-4 items-start", mode === "cidr" && "grid-cols-1", mode === "http" && "grid-cols-3", - (mode === "host" || mode === "ssh") && - "grid-cols-2" + mode === "host" && "grid-cols-2", + mode === "ssh" && + sshServerMode !== "native" && + "grid-cols-3" )} > {mode === "http" && ( @@ -996,7 +1071,11 @@ export function InternalResourceForm({ /> )} - {sshServerMode !== "native" && ( + {((mode === "ssh" && + sshServerMode !== "native") || + mode === "http" || + mode === "host" || + mode === "cidr") && (
)} - {(mode === "host" || mode === "ssh") && - sshServerMode !== "native" && ( -
- ( - - - {t(aliasLabelKey)} - - - - - - - )} - /> -
- )} - {mode === "http" && ( + {(mode === "host" || + (mode === "ssh" && + sshServerMode !== "native")) && (
( + + + {t(aliasLabelKey)} + + + + + + + )} + /> +
+ )} + {(mode === "http" || + (mode === "ssh" && + sshServerMode !== "native")) && ( +
+ ( {t( - httpHttpsPortLabelKey + destinationPortLabelKey )} @@ -1690,6 +1772,16 @@ export function InternalResourceForm({ "authDaemonPort", null ); + // Trim to single site + if (selectedSites.length > 1) { + const first = + selectedSites.slice(0, 1); + setSelectedSites(first); + form.setValue( + "siteIds", + first.map((s) => s.siteId) + ); + } } else { form.setValue( "authDaemonMode", diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index 080074d87..caa69b7b4 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -73,7 +73,7 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, - httpHttpsPort: row.destinationPort ?? null, + destinationPort: row.destinationPort ?? null, scheme: row.scheme }); return ( @@ -149,7 +149,7 @@ function PrivateAccessMethod({ row }: { row: SiteResourceRow }) { const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, destination: row.destination, - httpHttpsPort: row.destinationPort, + destinationPort: row.destinationPort, scheme: row.scheme }); return ( diff --git a/src/lib/formatSiteResourceAccess.ts b/src/lib/formatSiteResourceAccess.ts index b6fefa1d8..45e774a3e 100644 --- a/src/lib/formatSiteResourceAccess.ts +++ b/src/lib/formatSiteResourceAccess.ts @@ -1,16 +1,16 @@ export type SiteResourceDestinationInput = { mode: "host" | "cidr" | "http"; destination: string; - httpHttpsPort: number | null; + destinationPort: number | null; scheme: "http" | "https" | null; }; export function resolveHttpHttpsDisplayPort( mode: "http", - httpHttpsPort: number | null + destinationPort: number | null ): number { - if (httpHttpsPort != null) { - return httpHttpsPort; + if (destinationPort != null) { + return destinationPort; } return 80; } @@ -18,11 +18,11 @@ export function resolveHttpHttpsDisplayPort( export function formatSiteResourceDestinationDisplay( row: SiteResourceDestinationInput ): string { - const { mode, destination, httpHttpsPort, scheme } = row; + const { mode, destination, destinationPort, scheme } = row; if (mode !== "http") { return destination; } - const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); + const port = resolveHttpHttpsDisplayPort(mode, destinationPort); const downstreamScheme = scheme ?? "http"; const hostPart = destination.includes(":") && !destination.startsWith("[")