"use client"; import CopyTextBox from "@app/components/CopyTextBox"; import DomainPicker from "@app/components/DomainPicker"; import HealthCheckCredenza from "@app/components/HealthCheckCredenza"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { OptionSelect, type OptionSelectOption } from "@app/components/OptionSelect"; import { StrategySelect, type StrategyOption } from "@app/components/StrategySelect"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { SitesSelector, type Selectedsite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@app/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries } from "@app/lib/queries"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { Resource } from "@server/db"; import { isTargetValid } from "@server/lib/validators"; import { ListTargetsResponse } from "@server/routers/target"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { ArrayElement } from "@server/types/ArrayElement"; import { useQuery } from "@tanstack/react-query"; import { LocalTarget, ProxyResourceTargetsForm } from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; import { ColumnDef, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ChevronsUpDown, CircleCheck, CircleX, ExternalLink, Info, Plus, Settings, SquareArrowOutUpRight } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { toASCII } from "punycode"; import { useMemo, useState, useCallback, useTransition, useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { cn } from "@app/lib/cn"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), http: z.boolean() }); const httpResourceFormSchema = z.object({ domainId: z.string().nonempty(), subdomain: z.string().optional() }); const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), proxyPort: z.int().min(1).max(65535) }); const sshDaemonPortSchema = z.object({ authDaemonPort: z.string().refine( (val) => { if (!val) return true; const n = Number(val); return Number.isInteger(n) && n >= 1 && n <= 65535; }, { message: "Port must be between 1 and 65535" } ) }); const addTargetSchema = z .object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), siteId: z.int().positive(), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) .optional() .nullable(), rewritePath: z.string().optional().nullable(), rewritePathType: z .enum(["exact", "prefix", "regex", "stripPrefix"]) .optional() .nullable(), priority: z.int().min(1).max(1000).optional() }) .refine( (data) => { if (data.path && !data.pathMatchType) { return false; } if (data.pathMatchType && !data.path) { return false; } if (data.path && data.pathMatchType) { switch (data.pathMatchType) { case "exact": case "prefix": return data.path.startsWith("/"); case "regex": try { new RegExp(data.path); return true; } catch { return false; } } } return true; }, { error: "Invalid path configuration" } ) .refine( (data) => { if (data.rewritePath && !data.rewritePathType) { return false; } if (data.rewritePathType && !data.rewritePath) { if (data.rewritePathType !== "stripPrefix") { return false; } } return true; }, { error: "Invalid rewrite path configuration" } ); type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp"; export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const { data: sites = [], isLoading: loadingPage } = useQuery( orgQueries.sites({ orgId: orgId as string }) ); const { isPaidUser } = usePaidStatus(); const [remoteExitNodes, setRemoteExitNodes] = useState< ListRemoteExitNodesResponse["remoteExitNodes"] >([]); const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [createLoading, startTransition] = useTransition(); const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); // Resource type state const [resourceType, setResourceType] = useState("http"); const isBrowserGatewayType = resourceType === "ssh" || resourceType === "rdp" || resourceType === "vnc"; const browserGatewayDisabled = isBrowserGatewayType && !isPaidUser(tierMatrix[TierFeature.AdvancedPublicResources]); // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); // SSH-specific state const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( "native" ); const [pamMode, setPamMode] = useState<"passthrough" | "push">( "passthrough" ); const [standardDaemonLocation, setStandardDaemonLocation] = useState< "site" | "remote" >("site"); const [nativeSelectedSite, setNativeSelectedSite] = useState(null); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); // Browser-gateway targets state (SSH standard, RDP, VNC) const [bgSelectedSites, setBgSelectedSites] = useState([]); const [bgSelectedSite, setBgSelectedSite] = useState( null ); const [bgDestination, setBgDestination] = useState(""); const [bgDestinationPort, setBgDestinationPort] = useState("22"); // Reset BG state when resource type changes useEffect(() => { if (resourceType === "rdp") { setBgDestinationPort("3389"); } else if (resourceType === "vnc") { setBgDestinationPort("5900"); } else if (resourceType === "ssh") { setBgDestinationPort("22"); } setBgDestination(""); setBgSelectedSites([]); setBgSelectedSite(null); setNativeSelectedSite(null); }, [resourceType]); useEffect(() => { if (build !== "saas") return; const fetchExitNodes = async () => { try { const res = await api.get< AxiosResponse >(`/org/${orgId}/remote-exit-nodes`); if (res && res.status === 200) { setRemoteExitNodes(res.data.data.remoteExitNodes); } } catch (e) { console.error("Failed to fetch remote exit nodes:", e); } finally { setLoadingExitNodes(false); } }; fetchExitNodes(); }, [orgId]); // Derived flags const isHttpResource = resourceType !== "tcp" && resourceType !== "udp"; const isNative = sshServerMode === "native"; const showDaemonLocation = resourceType === "ssh" && !isNative && pamMode === "push"; const showDaemonPort = resourceType === "ssh" && !isNative && pamMode === "push" && standardDaemonLocation === "remote"; // Whether raw (TCP/UDP) resources are available const rawResourcesAllowed = env.flags.allowRawResources && (build !== "saas" || remoteExitNodes.length > 0); const availableTypes = useMemo((): NewResourceType[] => { const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"]; if (rawResourcesAllowed) { base.push("tcp", "udp"); } return base; }, [rawResourcesAllowed]); const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), defaultValues: { name: "", http: true } }); const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), defaultValues: {} }); const tcpUdpForm = useForm({ resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", proxyPort: undefined } }); const sshDaemonPortForm = useForm({ resolver: zodResolver(sshDaemonPortSchema), defaultValues: { authDaemonPort: "22123" } }); // Sync form http field with resourceType useEffect(() => { baseForm.setValue("http", isHttpResource); if (resourceType === "tcp") { tcpUdpForm.setValue("protocol", "tcp"); } else if (resourceType === "udp") { tcpUdpForm.setValue("protocol", "udp"); } }, [resourceType, isHttpResource]); const areAllTargetsValid = () => { if (targets.length === 0) return true; return targets.every((target) => { try { const isHttp = resourceType === "http"; const targetData: any = { ip: target.ip, method: target.method, port: target.port, siteId: target.siteId, path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, rewritePathType: target.rewritePathType }; if (isHttp) { targetData.priority = target.priority; } addTargetSchema.parse(targetData); return true; } catch { return false; } }); }; async function onSubmit() { const baseData = baseForm.getValues(); try { const payload: any = { name: baseData.name, http: isHttpResource }; let sanitizedSubdomain: string | undefined; if (isHttpResource) { const httpData = httpForm.getValues(); sanitizedSubdomain = httpData.subdomain ? finalizeSubdomainSanitize(httpData.subdomain, true) : undefined; const effectiveMode = isNative ? "native" : standardDaemonLocation; const portVal = sshDaemonPortForm.getValues().authDaemonPort; const effectivePort = !isNative && standardDaemonLocation === "remote" && pamMode === "push" && portVal ? Number(portVal) : undefined; Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, protocol: "tcp", mode: resourceType, pamMode, authDaemonMode: effectiveMode, authDaemonPort: effectivePort || undefined }); } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { protocol: tcpUdpData.protocol, proxyPort: tcpUdpData.proxyPort }); } const res = await api .put< AxiosResponse >(`/org/${orgId}/resource/`, payload) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorCreate"), description: formatAxiosError( e, t("resourceErrorCreateDescription") ) }); }); if (res && res.status === 201) { const id = res.data.data.resourceId; const newNiceId = res.data.data.niceId; setNiceId(newNiceId); if (resourceType === "http") { if (targets.length > 0) { try { for (const target of targets) { const data: any = { ip: target.ip, port: target.port, method: target.method, enabled: target.enabled, siteId: target.siteId, hcEnabled: target.hcEnabled, hcPath: target.hcPath || null, hcMethod: target.hcMethod || null, hcInterval: target.hcInterval || null, hcTimeout: target.hcTimeout || null, hcHeaders: target.hcHeaders || null, hcScheme: target.hcScheme || null, hcHostname: target.hcHostname || null, hcPort: target.hcPort || null, hcFollowRedirects: target.hcFollowRedirects || null, hcStatus: target.hcStatus || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null, hcTlsServerName: target.hcTlsServerName, hcHealthyThreshold: target.hcHealthyThreshold || null, hcUnhealthyThreshold: target.hcUnhealthyThreshold || null, path: target.path, pathMatchType: target.pathMatchType, rewritePath: target.rewritePath, rewritePathType: target.rewritePathType, priority: target.priority }; await api.put(`/resource/${id}/target`, data); } } catch (targetError) { console.error( "Error creating targets:", targetError ); toast({ variant: "destructive", title: t("targetErrorCreate"), description: formatAxiosError( targetError, t("targetErrorCreateDescription") ) }); } } router.push( `/${orgId}/settings/resources/public/${newNiceId}` ); } else if (resourceType === "ssh") { if (isNative) { if (nativeSelectedSite) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: nativeSelectedSite.siteId, type: "ssh", destination: "localhost", destinationPort: 22 } ); } } else { const sitesToCreate = standardDaemonLocation !== "site" ? bgSelectedSites : bgSelectedSite ? [bgSelectedSite] : []; for (const site of sitesToCreate) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: "ssh", destination: bgDestination, destinationPort: Number(bgDestinationPort) } ); } } router.push( `/${orgId}/settings/resources/public/${newNiceId}` ); } else if (resourceType === "rdp" || resourceType === "vnc") { for (const site of bgSelectedSites) { await api.put( `/org/${orgId}/resource/${id}/browser-gateway-target`, { siteId: site.siteId, type: resourceType, destination: bgDestination, destinationPort: Number(bgDestinationPort) } ); } router.push( `/${orgId}/settings/resources/public/${newNiceId}` ); } else { // TCP / UDP — create targets then show snippets if (targets.length > 0) { try { for (const target of targets) { const data: any = { ip: target.ip, port: target.port, method: target.method, enabled: target.enabled, siteId: target.siteId, hcEnabled: target.hcEnabled, hcPath: target.hcPath || null, hcMethod: target.hcMethod || null, hcInterval: target.hcInterval || null, hcTimeout: target.hcTimeout || null, hcHeaders: target.hcHeaders || null, hcScheme: target.hcScheme || null, hcHostname: target.hcHostname || null, hcPort: target.hcPort || null, hcFollowRedirects: target.hcFollowRedirects || null, hcStatus: target.hcStatus || null, hcUnhealthyInterval: target.hcUnhealthyInterval || null, hcMode: target.hcMode || null, hcTlsServerName: target.hcTlsServerName, hcHealthyThreshold: target.hcHealthyThreshold || null, hcUnhealthyThreshold: target.hcUnhealthyThreshold || null }; await api.put(`/resource/${id}/target`, data); } } catch (targetError) { console.error( "Error creating targets:", targetError ); toast({ variant: "destructive", title: t("targetErrorCreate"), description: formatAxiosError( targetError, t("targetErrorCreateDescription") ) }); } } setShowSnippets(true); router.refresh(); } } } catch (e) { console.error(t("resourceErrorCreateMessage"), e); toast({ variant: "destructive", title: t("resourceErrorCreate"), description: formatAxiosError( e, t("resourceErrorCreateMessageDescription") ) }); } } // SSH strategy options const sshModeOptions: StrategyOption<"standard" | "native">[] = [ { id: "native", title: t("sshServerModePangolin"), description: t("sshServerModeNativeDescription") }, { id: "standard", title: t("sshServerModeStandard"), description: t("sshServerModeStandardDescription") } ]; const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ { id: "passthrough", title: t("sshAuthMethodManual"), description: t("sshAuthMethodManualDescription") }, { id: "push", title: t("sshAuthMethodAutomated"), description: t("sshAuthMethodAutomatedDescription") } ]; const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [ { id: "site", title: t("internalResourceAuthDaemonSite"), description: t("sshDaemonLocationSiteDescription") }, { id: "remote", title: t("sshDaemonLocationRemote"), description: t("sshDaemonLocationRemoteDescription") } ]; const typeLabels: Record = { http: "HTTP", ssh: "SSH", rdp: "RDP", vnc: "VNC", tcp: "TCP", udp: "UDP" }; const typeOptions: OptionSelectOption[] = availableTypes.map((type) => ({ value: type, label: typeLabels[type] })); return ( <>
{!loadingPage && (
{!showSnippets ? ( {/* General Section */} {t("resourceCreateGeneral")} {t("resourceCreateGeneralDescription")} {/* Name */}
{ if (e.key === "Enter") { e.preventDefault(); } }} className="space-y-4" id="base-resource-form" > ( {t("name")} {t( "resourceNameDescription" )} )} /> {/* Inline Type Selector */}

{t("type")}

options={typeOptions} value={resourceType} onChange={setResourceType} cols={6} />

{t("resourceTypeDescription")}

{/* Domain/Subdomain (HTTP-based types) */} {isHttpResource && (
= 1 } onDomainChange={(res) => { if (!res) return; httpForm.setValue( "subdomain", res.subdomain ); httpForm.setValue( "domainId", res.domainId ); }} />

{t( "resourceDomainDescription" )}

)} {/* Proxy Port (TCP/UDP types) */} {!isHttpResource && (
{ if (e.key === "Enter") { e.preventDefault(); } }} className="space-y-4" id="tcp-udp-settings-form" > ( {t( "resourcePortNumber" )} field.onChange( e .target .value ? parseInt( e .target .value ) : undefined ) } /> {t( "resourcePortDescription" )} )} /> )}
{/* SSH Server Section */} {resourceType === "ssh" && ( {t("sshServer")} {t("sshServerDescription")}
{/* Mode */}

{t("sshServerMode")}

value={sshServerMode} options={sshModeOptions} onChange={setSshServerMode} cols={2} />

{t( "sshAuthenticationMethod" )}

value={pamMode} options={ authMethodOptions } onChange={setPamMode} cols={2} />
{/* Daemon Location (standard + push) */} {showDaemonLocation && (

{t( "sshAuthDaemonLocation" )}

value={ standardDaemonLocation } options={ daemonLocationOptions } onChange={ setStandardDaemonLocation } cols={2} />

{t( "sshDaemonDisclaimer" )}{" "} {t("learnMore")}

)} {/* Daemon Port (standard + push + remote) */} {showDaemonPort && (
( {t( "sshDaemonPort" )} )} /> )} {/* Server Destination */}

{t( "sshServerDestination" )}

{t( "sshServerDestinationDescription" )}

{isNative ? ( { setNativeSelectedSite( site ); setNativeSiteOpen( false ); }} /> ) : standardDaemonLocation !== "site" || pamMode === "passthrough" ? ( ) : ( )}
)} {/* RDP Server Section */} {resourceType === "rdp" && ( {t("rdpServer")} {t("rdpServerDescription")}
)} {/* VNC Server Section */} {resourceType === "vnc" && ( {t("vncServer")} {t("vncServerDescription")}
)} {/* Targets Section (HTTP / TCP / UDP) */} {(resourceType === "http" || resourceType === "tcp" || resourceType === "udp") && ( )}
) : ( {t("resourceConfig")} {t("resourceConfigDescription")}

{t("resourceAddEntrypoints")}

{t( "resourceAddEntrypointsEditFile" )}

{t("resourceExposePorts")}

{t( "resourceExposePortsEditFile" )}

{t("resourceLearnRaw")}
)}
)} ); }