"use client"; import CopyTextBox from "@app/components/CopyTextBox"; import DomainPicker from "@app/components/DomainPicker"; import { SettingsContainer, SettingsFormCell, SettingsFormGrid, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle, SettingsSubsectionDescription, SettingsSubsectionHeader, SettingsSubsectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { DescribedSelect, type DescribedSelectOption } from "@app/components/DescribedSelect"; 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 { Label } from "@app/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; 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 { createBrowserGatewayTargetFormSchema, createSshSettingsFormSchema, selectedSiteSchema, type SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema"; 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 { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { useQuery } from "@tanstack/react-query"; import { LocalTarget, ProxyResourceTargetsForm } from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm"; import { AiProvidersSelector, type SelectedAiProvider } from "@app/components/AiProvidersSelector"; import { AxiosResponse } from "axios"; import { ChevronsUpDown, ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII } from "punycode"; import { useMemo, useState, useEffect } from "react"; import { useForm, type Resolver } from "react-hook-form"; import { z } from "zod"; type TranslateFn = (key: string) => string; function createBaseResourceFormSchema(t: TranslateFn) { return z.object({ name: z .string() .min(1, { message: t("nameRequired") }) .max(255, { message: t("createInternalResourceDialogNameMaxLength") }), http: z.boolean() }); } function createHttpResourceFormSchema(t: TranslateFn) { return z.object({ domainId: z.string().min(1, { message: t("domainRequired") }), subdomain: z.string().optional() }); } function createTcpUdpResourceFormSchema(t: TranslateFn) { return z.object({ protocol: z.string(), proxyPort: z .number({ error: t("proxyPortRequired") }) .int({ error: t("healthCheckPortInvalid") }) .min(1, { message: t("healthCheckPortInvalid") }) .max(65535, { message: t("healthCheckPortInvalid") }) }); } function createSshDaemonPortSchema(t: TranslateFn) { return z.object({ authDaemonPort: z.string().refine( (val) => { if (!val) return true; const n = Number(val); return Number.isInteger(n) && n >= 1 && n <= 65535; }, { message: t("healthCheckPortInvalid") } ) }); } function createAddTargetSchema(t: TranslateFn) { return z .object({ ip: z.string().refine(isTargetValid, { message: t("targetErrorInvalidIpDescription") }), method: z.string().nullable(), port: z.coerce .number({ error: t("targetErrorInvalidPortDescription") }) .int({ error: t("targetErrorInvalidPortDescription") }) .positive({ error: t("targetErrorInvalidPortDescription") }), siteId: z .int({ error: t("siteRequired") }) .positive({ error: t("siteRequired") }), 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, { message: t("healthCheckPortInvalid") }) .max(1000, { message: t("healthCheckPortInvalid") }) .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; }, { message: t("invalidPathConfiguration") } ) .refine( (data) => { if (data.rewritePath && !data.rewritePathType) { return false; } if (data.rewritePathType && !data.rewritePath) { if (data.rewritePathType !== "stripPrefix") { return false; } } return true; }, { message: t("invalidRewritePathConfiguration") } ); } type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp" | "inference"; type CreateBgTargetFormValues = SshSettingsFormValues; 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, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); const [niceId, setNiceId] = useState(""); // Resource type state const [resourceType, setResourceType] = useState("http"); // Target management state (managed by ProxyResourceTargetsForm; mirrored here for onSubmit) const [targets, setTargets] = useState([]); const [selectedProviders, setSelectedProviders] = useState< SelectedAiProvider[] >([]); // 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); 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"; const bgTargetFormSchema = useMemo(() => { if (resourceType === "ssh" && !isNative) { return createSshSettingsFormSchema(t, { isNative: false }); } if (resourceType === "rdp" || resourceType === "vnc") { return createBrowserGatewayTargetFormSchema(t); } return z.object({ selectedSites: z.array(selectedSiteSchema), selectedSite: selectedSiteSchema.nullable(), destination: z.string(), destinationPort: z.string(), pamMode: z.enum(["passthrough", "push"]), standardDaemonLocation: z.enum(["site", "remote"]) }); }, [resourceType, isNative, t]); const bgTargetForm = useForm({ resolver: zodResolver( bgTargetFormSchema ) as unknown as Resolver, defaultValues: { selectedSites: [], selectedSite: null, selectedNativeSite: null, destination: "", destinationPort: "22", pamMode: "passthrough", standardDaemonLocation: "site", authDaemonPort: "22123" } }); // Whether raw (TCP/UDP) resources are available const rawResourcesAllowed = env.flags.allowRawResources && (build !== "saas" || remoteExitNodes.length > 0); const enterpriseModesAllowed = !env.flags.disableEnterpriseFeatures; const availableTypes = useMemo((): NewResourceType[] => { const base: NewResourceType[] = ["http", "inference"]; if (enterpriseModesAllowed) { base.push("ssh", "rdp", "vnc"); } if (rawResourcesAllowed) { base.push("tcp", "udp"); } return base; }, [enterpriseModesAllowed, rawResourcesAllowed]); useEffect(() => { if (!availableTypes.includes(resourceType)) { setResourceType("http"); } }, [availableTypes, resourceType]); const baseResourceFormSchema = useMemo( () => createBaseResourceFormSchema(t), [t] ); const httpResourceFormSchema = useMemo( () => createHttpResourceFormSchema(t), [t] ); const tcpUdpResourceFormSchema = useMemo( () => createTcpUdpResourceFormSchema(t), [t] ); const sshDaemonPortSchema = useMemo( () => createSshDaemonPortSchema(t), [t] ); const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]); 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" } }); useEffect(() => { const defaultPort = resourceType === "rdp" ? "3389" : resourceType === "vnc" ? "5900" : "22"; bgTargetForm.reset({ selectedSites: [], selectedSite: null, selectedNativeSite: null, destination: "", destinationPort: defaultPort, pamMode, standardDaemonLocation, authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort }); setNativeSelectedSite(null); }, [resourceType]); useEffect(() => { bgTargetForm.setValue("pamMode", pamMode); bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation); }, [pamMode, standardDaemonLocation]); // 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() { setCreateLoading(true); 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; Object.assign(payload, { subdomain: sanitizedSubdomain ? toASCII(sanitizedSubdomain) : undefined, domainId: httpData.domainId, protocol: "tcp", mode: resourceType }); if (resourceType === "inference") { Object.assign(payload, { aiProviders: selectedProviders.map((provider) => ({ providerId: parseInt(provider.id, 10) })) }); } else if (resourceType === "ssh") { const effectiveMode = isNative ? "native" : standardDaemonLocation; const portVal = sshDaemonPortForm.getValues().authDaemonPort; const effectivePort = !isNative && standardDaemonLocation === "remote" && pamMode === "push" && portVal ? Number(portVal) : undefined; Object.assign(payload, { 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 === "inference") { router.push( `/${orgId}/settings/resources/public/${newNiceId}/general` ); } else 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( `/resource/${id}/target`, { siteId: nativeSelectedSite.siteId, mode: "ssh", ip: "localhost", port: 22, hcEnabled: false } ); } } else { const bgValues = bgTargetForm.getValues(); const useMultiSite = standardDaemonLocation !== "site" || pamMode === "passthrough"; const sitesToCreate = useMultiSite ? bgValues.selectedSites : bgValues.selectedSite ? [bgValues.selectedSite] : []; for (const site of sitesToCreate) { await api.put( `/resource/${id}/target`, { siteId: site.siteId, mode: "ssh", ip: bgValues.destination, port: Number(bgValues.destinationPort), hcEnabled: false } ); } } router.push( `/${orgId}/settings/resources/public/${newNiceId}` ); } else if (resourceType === "rdp" || resourceType === "vnc") { const bgValues = bgTargetForm.getValues(); for (const site of bgValues.selectedSites) { await api.put( `/resource/${id}/target`, { siteId: site.siteId, mode: resourceType, ip: bgValues.destination, port: Number(bgValues.destinationPort), hcEnabled: false } ); } 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") ) }); } finally { setCreateLoading(false); } } // 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 typeMeta: Record< NewResourceType, { title: string; description: string } > = { http: { title: t("createInternalResourceDialogModeHttp"), description: t("resourceTypeHttpDescription") }, inference: { title: t("createInternalResourceDialogModeInference"), description: t("resourceTypeInferenceDescription") }, ssh: { title: t("createInternalResourceDialogModeSsh"), description: t("resourceTypeSshDescription") }, rdp: { title: t("rdpTitle"), description: t("resourceTypeRdpDescription") }, vnc: { title: t("vncTitle"), description: t("resourceTypeVncDescription") }, tcp: { title: t("createInternalResourceDialogTcp"), description: t("resourceTypeTcpDescription") }, udp: { title: t("createInternalResourceDialogUdp"), description: t("resourceTypeUdpDescription") } }; const typeOptions: DescribedSelectOption[] = availableTypes.map((type) => ({ value: type, title: typeMeta[type].title, description: typeMeta[type].description })); return ( <>
{!loadingPage && (
{!showSnippets ? ( {/* General Section */} {t("resourceCreateGeneral")} {t("resourceCreateGeneralDescription")}
options={typeOptions} value={resourceType} onChange={ setResourceType } searchPlaceholder={t( "resourceTypeSearch" )} emptyMessage={t( "resourceTypeNotFound" )} placeholder={t( "noneSelected" )} />

{t( "resourceTypeDescription" )}

{ if ( e.key === "Enter" ) { e.preventDefault(); } }} id="base-resource-form" > ( {t( "name" )} {t( "resourceNameDescription" )} )} />
{isHttpResource && (
( = 1 } onDomainChange={( res ) => { if ( !res ) return; httpForm.setValue( "subdomain", res.subdomain, { shouldValidate: true } ); httpForm.setValue( "domainId", res.domainId, { shouldValidate: true } ); }} /> {t( "resourceDomainDescription" )} )} />
)} {!isHttpResource && (
{ if ( e.key === "Enter" ) { e.preventDefault(); } }} 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")}

{t("sshServerMode")}

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

{t( "sshAuthenticationMethod" )}

value={pamMode} options={ authMethodOptions } onChange={setPamMode} cols={2} />
{showDaemonLocation && (

{t( "sshAuthDaemonLocation" )}

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

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

)} {showDaemonPort && (
( {t( "sshDaemonPort" )} )} />
)} {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") && ( )} {resourceType === "inference" && ( {t("aiResourceProviders")} {t( "aiResourceProvidersDescription" )}
{ setSelectedProviders( providers ); }} />
)}
) : ( {t("resourceConfig")} {t("resourceConfigDescription")}{" "} {t("learnMore")} {t("resourceAddEntrypoints")} {t( "resourceAddEntrypointsEditFile" )} {t("resourceExposePorts")} {t( "resourceExposePortsEditFile" )}
)}
)} ); }