Basic clients working

This commit is contained in:
Owen 2025-07-27 10:21:27 -07:00
parent 15adfcca8c
commit 28f8b05dbc
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
21 changed files with 387 additions and 87 deletions

View file

@ -1093,7 +1093,7 @@
"sidebarAllUsers": "All Users", "sidebarAllUsers": "All Users",
"sidebarIdentityProviders": "Identity Providers", "sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License", "sidebarLicense": "License",
"sidebarClients": "Clients", "sidebarClients": "Clients (beta)",
"sidebarDomains": "Domains", "sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket", "enableDockerSocket": "Enable Docker Socket",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
@ -1315,5 +1315,8 @@
"olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.",
"remoteSubnets": "Remote Subnets", "remoteSubnets": "Remote Subnets",
"enterCidrRange": "Enter CIDR range", "enterCidrRange": "Enter CIDR range",
"remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24." "remoteSubnetsDescription": "Add CIDR ranges that can access this site remotely. Use format like 10.0.0.0/24 or 192.168.1.0/24.",
"resourceEnableProxy": "Enable Public Proxy",
"resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.",
"externalProxyEnabled": "External Proxy Enabled"
} }

View file

@ -94,7 +94,8 @@ export const resources = pgTable("resources", {
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
stickySession: boolean("stickySession").notNull().default(false), stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"), tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader") setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").notNull().default(true),
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {

View file

@ -106,7 +106,8 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
tlsServerName: text("tlsServerName"), tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader") setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {

View file

@ -147,7 +147,8 @@ export async function updateClient(
endpoint: site.endpoint, endpoint: site.endpoint,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
}); });
} }

View file

@ -2,9 +2,16 @@ import { z } from "zod";
import { MessageHandler } from "../ws"; import { MessageHandler } from "../ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { db, ExitNode, exitNodes } from "@server/db"; import {
db,
ExitNode,
exitNodes,
resources,
Target,
targets
} from "@server/db";
import { clients, clientSites, Newt, sites } from "@server/db"; import { clients, clientSites, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import { updatePeer } from "../olm/peers"; import { updatePeer } from "../olm/peers";
import axios from "axios"; import axios from "axios";
@ -191,7 +198,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
endpoint: endpoint, endpoint: endpoint,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
}); });
} catch (error) { } catch (error) {
logger.error( logger.error(
@ -212,14 +220,96 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
// Filter out any null values from peers that didn't have an olm // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); const validPeers = peers.filter((peer) => peer !== null);
// Improved version
const allResources = await db.transaction(async (tx) => {
// First get all resources for the site
const resourcesList = await tx
.select({
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol
})
.from(resources)
.where(and(eq(resources.siteId, siteId), eq(resources.http, false)));
// Get all enabled targets for these resources in a single query
const resourceIds = resourcesList.map((r) => r.resourceId);
const allTargets =
resourceIds.length > 0
? await tx
.select({
resourceId: targets.resourceId,
targetId: targets.targetId,
ip: targets.ip,
method: targets.method,
port: targets.port,
internalPort: targets.internalPort,
enabled: targets.enabled,
})
.from(targets)
.where(
and(
inArray(targets.resourceId, resourceIds),
eq(targets.enabled, true)
)
)
: [];
// Combine the data in JS instead of using SQL for the JSON
return resourcesList.map((resource) => ({
...resource,
targets: allTargets.filter(
(target) => target.resourceId === resource.resourceId
)
}));
});
const { tcpTargets, udpTargets } = allResources.reduce(
(acc, resource) => {
// Skip resources with no targets
if (!resource.targets?.length) return acc;
// Format valid targets into strings
const formattedTargets = resource.targets
.filter(
(target: Target) =>
resource.proxyPort && target?.ip && target?.port
)
.map(
(target: Target) =>
`${resource.proxyPort}:${target.ip}:${target.port}`
);
// Add to the appropriate protocol array
if (resource.protocol === "tcp") {
acc.tcpTargets.push(...formattedTargets);
} else {
acc.udpTargets.push(...formattedTargets);
}
return acc;
},
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
);
// Build the configuration response // Build the configuration response
const configResponse = { const configResponse = {
ipAddress: site.address, ipAddress: site.address,
peers: validPeers peers: validPeers,
targets: {
udp: udpTargets,
tcp: tcpTargets
}
}; };
logger.debug("Sending config: ", configResponse); logger.debug("Sending config: ", configResponse);
return { return {
message: { message: {
type: "newt/wg/receive-config", type: "newt/wg/receive-config",

View file

@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
export function addTargets( export function addTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string protocol: string,
port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@ -13,19 +14,32 @@ export function addTargets(
}:${target.port}`; }:${target.port}`;
}); });
const payload = { sendToClient(newtId, {
type: `newt/${protocol}/add`, type: `newt/${protocol}/add`,
data: { data: {
targets: payloadTargets targets: payloadTargets
} }
}; });
sendToClient(newtId, payload);
const payloadTargetsResources = targets.map((target) => {
return `${port ? port + ":" : ""}${
target.ip
}:${target.port}`;
});
sendToClient(newtId, {
type: `newt/wg/${protocol}/add`,
data: {
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
}
});
} }
export function removeTargets( export function removeTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string protocol: string,
port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@ -34,11 +48,23 @@ export function removeTargets(
}:${target.port}`; }:${target.port}`;
}); });
const payload = { sendToClient(newtId, {
type: `newt/${protocol}/remove`, type: `newt/${protocol}/remove`,
data: { data: {
targets: payloadTargets targets: payloadTargets
} }
}; });
sendToClient(newtId, payload);
const payloadTargetsResources = targets.map((target) => {
return `${port ? port + ":" : ""}${
target.ip
}:${target.port}`;
});
sendToClient(newtId, {
type: `newt/wg/${protocol}/remove`,
data: {
targets: [payloadTargetsResources[0]] // We can only use one target for WireGuard right now
}
});
} }

View file

@ -119,12 +119,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
continue; continue;
} }
if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
logger.warn( // logger.warn(
`Site ${site.siteId} last hole punch is too old, skipping` // `Site ${site.siteId} last hole punch is too old, skipping`
); // );
continue; // continue;
} // }
// If public key changed, delete old peer from this site // If public key changed, delete old peer from this site
if (client.pubKey && client.pubKey != publicKey) { if (client.pubKey && client.pubKey != publicKey) {
@ -175,7 +175,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
endpoint: endpoint, endpoint: endpoint,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
}); });
} }

View file

@ -12,6 +12,7 @@ export async function addPeer(
endpoint: string; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
remoteSubnets: string | null; // optional, comma-separated list of subnets that this site can access
} }
) { ) {
const [olm] = await db const [olm] = await db
@ -30,7 +31,8 @@ export async function addPeer(
publicKey: peer.publicKey, publicKey: peer.publicKey,
endpoint: peer.endpoint, endpoint: peer.endpoint,
serverIP: peer.serverIP, serverIP: peer.serverIP,
serverPort: peer.serverPort serverPort: peer.serverPort,
remoteSubnets: peer.remoteSubnets // optional, comma-separated list of subnets that this site can access
} }
}); });
@ -66,6 +68,7 @@ export async function updatePeer(
endpoint: string; endpoint: string;
serverIP: string | null; serverIP: string | null;
serverPort: number | null; serverPort: number | null;
remoteSubnets?: string | null; // optional, comma-separated list of subnets that
} }
) { ) {
const [olm] = await db const [olm] = await db
@ -84,7 +87,8 @@ export async function updatePeer(
publicKey: peer.publicKey, publicKey: peer.publicKey,
endpoint: peer.endpoint, endpoint: peer.endpoint,
serverIP: peer.serverIP, serverIP: peer.serverIP,
serverPort: peer.serverPort serverPort: peer.serverPort,
remoteSubnets: peer.remoteSubnets
} }
}); });

View file

@ -40,7 +40,7 @@ const createHttpResourceSchema = z
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.enum(["tcp", "udp"]), protocol: z.enum(["tcp", "udp"]),
domainId: z.string() domainId: z.string(),
}) })
.strict() .strict()
.refine( .refine(
@ -59,7 +59,8 @@ const createRawResourceSchema = z
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.enum(["tcp", "udp"]), protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().min(1).max(65535) proxyPort: z.number().int().min(1).max(65535),
enableProxy: z.boolean().default(true)
}) })
.strict() .strict()
.refine( .refine(
@ -378,7 +379,7 @@ async function createRawResource(
); );
} }
const { name, http, protocol, proxyPort } = parsedBody.data; const { name, http, protocol, proxyPort, enableProxy } = parsedBody.data;
// if http is false check to see if there is already a resource with the same port and protocol // if http is false check to see if there is already a resource with the same port and protocol
const existingResource = await db const existingResource = await db
@ -411,7 +412,8 @@ async function createRawResource(
name, name,
http, http,
protocol, protocol,
proxyPort proxyPort,
enableProxy
}) })
.returning(); .returning();

View file

@ -103,7 +103,8 @@ export async function deleteResource(
removeTargets( removeTargets(
newt.newtId, newt.newtId,
targetsToBeRemoved, targetsToBeRemoved,
deletedResource.protocol deletedResource.protocol,
deletedResource.proxyPort
); );
} }
} }

View file

@ -168,7 +168,8 @@ export async function transferResource(
removeTargets( removeTargets(
newt.newtId, newt.newtId,
resourceTargets, resourceTargets,
updatedResource.protocol updatedResource.protocol,
updatedResource.proxyPort
); );
} }
} }
@ -190,7 +191,8 @@ export async function transferResource(
addTargets( addTargets(
newt.newtId, newt.newtId,
resourceTargets, resourceTargets,
updatedResource.protocol updatedResource.protocol,
updatedResource.proxyPort
); );
} }
} }

View file

@ -93,7 +93,8 @@ const updateRawResourceBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional(),
enableProxy: z.boolean().optional(),
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {

View file

@ -173,7 +173,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, newTarget, resource.protocol); addTargets(newt.newtId, newTarget, resource.protocol, resource.proxyPort);
} }
} }
} }

View file

@ -105,7 +105,7 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
removeTargets(newt.newtId, [deletedTarget], resource.protocol); removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
} }
} }

View file

@ -157,7 +157,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
addTargets(newt.newtId, [updatedTarget], resource.protocol); addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
} }
} }
return response(res, { return response(res, {

View file

@ -66,7 +66,8 @@ export async function traefikConfigProvider(
enabled: resources.enabled, enabled: resources.enabled,
stickySession: resources.stickySession, stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName, tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy
}) })
.from(resources) .from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId)) .innerJoin(sites, eq(sites.siteId, resources.siteId))
@ -365,6 +366,10 @@ export async function traefikConfigProvider(
} }
} else { } else {
// Non-HTTP (TCP/UDP) configuration // Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) {
continue;
}
const protocol = resource.protocol.toLowerCase(); const protocol = resource.protocol.toLowerCase();
const port = resource.proxyPort; const port = resource.proxyPort;

View file

@ -0,0 +1,29 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.8.0";
export default async function migration() {
console.log("Running setup script ${version}...");
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.transaction(() => {
db.exec(`
ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text;
ALTER TABLE 'user' ADD 'termsVersion' text;
ALTER TABLE 'sites' ADD 'remoteSubnets' text;
`);
})();
console.log("Migrated database schema");
} catch (e) {
console.log("Unable to migrate database schema");
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Clients" title="Manage Clients (beta)"
description="Clients are devices that can connect to your sites" description="Clients are devices that can connect to your sites"
/> />

View file

@ -18,6 +18,7 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react"; import { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
type ResourceInfoBoxType = {}; type ResourceInfoBoxType = {};
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert> <Alert>
<InfoIcon className="h-4 w-4" /> <InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold"> <AlertTitle className="font-semibold">
{t('resourceInfo')} {t("resourceInfo")}
</AlertTitle> </AlertTitle>
<AlertDescription className="mt-4"> <AlertDescription className="mt-4">
<InfoSections cols={4}> <InfoSections cols={4}>
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t('authentication')} {t("authentication")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{authInfo.password || {authInfo.password ||
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? ( authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500"> <div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" /> <ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t('protected')}</span> <span>{t("protected")}</span>
</div> </div>
) : ( ) : (
<div className="flex items-center space-x-2 text-yellow-500"> <div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" /> <ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span> <span>{t("notProtected")}</span>
</div> </div>
)} )}
</InfoSectionContent> </InfoSectionContent>
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('site')}</InfoSectionTitle> <InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{resource.siteName} {resource.siteName}
</InfoSectionContent> </InfoSectionContent>
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : ( ) : (
<> <>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle> <InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span> <span>
{resource.protocol.toUpperCase()} {resource.protocol.toUpperCase()}
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('port')}</InfoSectionTitle> <InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<CopyToClipboard <CopyToClipboard
text={resource.proxyPort!.toString()} text={resource.proxyPort!.toString()}
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/> />
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)}
</> </>
)} )}
<InfoSection> <InfoSection>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle> <InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span> <span>
{resource.enabled ? t('enabled') : t('disabled')} {resource.enabled
? t("enabled")
: t("disabled")}
</span> </span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>

View file

@ -66,6 +66,7 @@ import {
} from "@server/routers/resource"; } from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@ -78,6 +79,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react"; import { Globe } from "lucide-react";
import { build } from "@server/build";
const TransferFormSchema = z.object({ const TransferFormSchema = z.object({
siteId: z.number() siteId: z.number()
@ -118,25 +120,31 @@ export default function GeneralForm() {
fullDomain: string; fullDomain: string;
} | null>(null); } | null>(null);
const GeneralFormSchema = z.object({ const GeneralFormSchema = z
enabled: z.boolean(), .object({
subdomain: z.string().optional(), enabled: z.boolean(),
name: z.string().min(1).max(255), subdomain: z.string().optional(),
domainId: z.string().optional(), name: z.string().min(1).max(255),
proxyPort: z.number().int().min(1).max(65535).optional() domainId: z.string().optional(),
}).refine((data) => { proxyPort: z.number().int().min(1).max(65535).optional(),
// For non-HTTP resources, proxyPort should be defined enableProxy: z.boolean().optional()
if (!resource.http) { })
return data.proxyPort !== undefined; .refine(
} (data) => {
// For HTTP resources, proxyPort should be undefined // For non-HTTP resources, proxyPort should be defined
return data.proxyPort === undefined; if (!resource.http) {
}, { return data.proxyPort !== undefined;
message: !resource.http }
? "Port number is required for non-HTTP resources" // For HTTP resources, proxyPort should be undefined
: "Port number should not be set for HTTP resources", return data.proxyPort === undefined;
path: ["proxyPort"] },
}); {
message: !resource.http
? "Port number is required for non-HTTP resources"
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -147,7 +155,8 @@ export default function GeneralForm() {
name: resource.name, name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined, domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined proxyPort: resource.proxyPort || undefined,
enableProxy: resource.enableProxy || false
}, },
mode: "onChange" mode: "onChange"
}); });
@ -211,7 +220,8 @@ export default function GeneralForm() {
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
domainId: data.domainId, domainId: data.domainId,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
enableProxy: data.enableProxy
} }
) )
.catch((e) => { .catch((e) => {
@ -238,7 +248,8 @@ export default function GeneralForm() {
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: resource.fullDomain, fullDomain: resource.fullDomain,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
enableProxy: data.enableProxy
}); });
router.refresh(); router.refresh();
@ -357,16 +368,29 @@ export default function GeneralForm() {
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t("resourcePortNumber")} {t(
"resourcePortNumber"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
value={field.value ?? ""} value={
onChange={(e) => field.value ??
""
}
onChange={(
e
) =>
field.onChange( field.onChange(
e.target.value e
? parseInt(e.target.value) .target
.value
? parseInt(
e
.target
.value
)
: undefined : undefined
) )
} }
@ -374,11 +398,49 @@ export default function GeneralForm() {
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
{t("resourcePortNumberDescription")} {t(
"resourcePortNumberDescription"
)}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
/> />
{build == "oss" && (
<FormField
control={form.control}
name="enableProxy"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
</> </>
)} )}

View file

@ -25,6 +25,7 @@ import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
const baseResourceFormSchema = z.object({ const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
@ -78,7 +80,8 @@ const httpResourceFormSchema = z.object({
const tcpUdpResourceFormSchema = z.object({ const tcpUdpResourceFormSchema = z.object({
protocol: z.string(), protocol: z.string(),
proxyPort: z.number().int().min(1).max(65535) proxyPort: z.number().int().min(1).max(65535),
enableProxy: z.boolean().default(false)
}); });
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>; type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
@ -144,7 +147,8 @@ export default function Page() {
resolver: zodResolver(tcpUdpResourceFormSchema), resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: { defaultValues: {
protocol: "tcp", protocol: "tcp",
proxyPort: undefined proxyPort: undefined,
enableProxy: false
} }
}); });
@ -163,16 +167,17 @@ export default function Page() {
if (isHttp) { if (isHttp) {
const httpData = httpForm.getValues(); const httpData = httpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain,
domainId: httpData.domainId, domainId: httpData.domainId,
protocol: "tcp", protocol: "tcp"
}); });
} else { } else {
const tcpUdpData = tcpUdpForm.getValues(); const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, { Object.assign(payload, {
protocol: tcpUdpData.protocol, protocol: tcpUdpData.protocol,
proxyPort: tcpUdpData.proxyPort proxyPort: tcpUdpData.proxyPort,
enableProxy: tcpUdpData.enableProxy
}); });
} }
@ -198,8 +203,15 @@ export default function Page() {
if (isHttp) { if (isHttp) {
router.push(`/${orgId}/settings/resources/${id}`); router.push(`/${orgId}/settings/resources/${id}`);
} else { } else {
setShowSnippets(true); const tcpUdpData = tcpUdpForm.getValues();
router.refresh(); // Only show config snippets if enableProxy is explicitly true
if (tcpUdpData.enableProxy === true) {
setShowSnippets(true);
router.refresh();
} else {
// If enableProxy is false or undefined, go directly to resource page
router.push(`/${orgId}/settings/resources/${id}`);
}
} }
} }
} catch (e) { } catch (e) {
@ -603,6 +615,46 @@ export default function Page() {
</FormItem> </FormItem>
)} )}
/> />
{build == "oss" && (
<FormField
control={
tcpUdpForm.control
}
name="enableProxy"
render={({
field
}) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
variant={
"outlinePrimarySquare"
}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
{t(
"resourceEnableProxy"
)}
</FormLabel>
<FormDescription>
{t(
"resourceEnableProxyDescription"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>