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",
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients",
"sidebarClients": "Clients (beta)",
"sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket",
"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.",
"remoteSubnets": "Remote Subnets",
"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),
stickySession: boolean("stickySession").notNull().default(false),
tlsServerName: varchar("tlsServerName"),
setHostHeader: varchar("setHostHeader")
setHostHeader: varchar("setHostHeader"),
enableProxy: boolean("enableProxy").notNull().default(true),
});
export const targets = pgTable("targets", {

View file

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

View file

@ -147,7 +147,8 @@ export async function updateClient(
endpoint: site.endpoint,
publicKey: site.publicKey,
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 logger from "@server/logger";
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 { eq } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { updatePeer } from "../olm/peers";
import axios from "axios";
@ -191,7 +198,8 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
endpoint: endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort
serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
});
} catch (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
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
const configResponse = {
ipAddress: site.address,
peers: validPeers
peers: validPeers,
targets: {
udp: udpTargets,
tcp: tcpTargets
}
};
logger.debug("Sending config: ", configResponse);
return {
message: {
type: "newt/wg/receive-config",

View file

@ -4,7 +4,8 @@ import { sendToClient } from "../ws";
export function addTargets(
newtId: string,
targets: Target[],
protocol: string
protocol: string,
port: number | null = null
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
@ -13,19 +14,32 @@ export function addTargets(
}:${target.port}`;
});
const payload = {
sendToClient(newtId, {
type: `newt/${protocol}/add`,
data: {
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(
newtId: string,
targets: Target[],
protocol: string
protocol: string,
port: number | null = null
) {
//create a list of udp and tcp targets
const payloadTargets = targets.map((target) => {
@ -34,11 +48,23 @@ export function removeTargets(
}:${target.port}`;
});
const payload = {
sendToClient(newtId, {
type: `newt/${protocol}/remove`,
data: {
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;
}
if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
logger.warn(
`Site ${site.siteId} last hole punch is too old, skipping`
);
continue;
}
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
// logger.warn(
// `Site ${site.siteId} last hole punch is too old, skipping`
// );
// continue;
// }
// If public key changed, delete old peer from this site
if (client.pubKey && client.pubKey != publicKey) {
@ -175,7 +175,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
endpoint: endpoint,
publicKey: site.publicKey,
serverIP: site.address,
serverPort: site.listenPort
serverPort: site.listenPort,
remoteSubnets: site.remoteSubnets
});
}

View file

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

View file

@ -40,7 +40,7 @@ const createHttpResourceSchema = z
siteId: z.number(),
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
domainId: z.string()
domainId: z.string(),
})
.strict()
.refine(
@ -59,7 +59,8 @@ const createRawResourceSchema = z
siteId: z.number(),
http: z.boolean(),
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()
.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
const existingResource = await db
@ -411,7 +412,8 @@ async function createRawResource(
name,
http,
protocol,
proxyPort
proxyPort,
enableProxy
})
.returning();

View file

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

View file

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

View file

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

View file

@ -173,7 +173,7 @@ export async function createTarget(
.where(eq(newts.siteId, site.siteId))
.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))
.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))
.limit(1);
addTargets(newt.newtId, [updatedTarget], resource.protocol);
addTargets(newt.newtId, [updatedTarget], resource.protocol, resource.proxyPort);
}
}
return response(res, {

View file

@ -66,7 +66,8 @@ export async function traefikConfigProvider(
enabled: resources.enabled,
stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
@ -365,6 +366,10 @@ export async function traefikConfigProvider(
}
} else {
// Non-HTTP (TCP/UDP) configuration
if (!resource.enableProxy) {
continue;
}
const protocol = resource.protocol.toLowerCase();
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 (
<>
<SettingsSectionTitle
title="Manage Clients"
title="Manage Clients (beta)"
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 { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
type ResourceInfoBoxType = {};
@ -34,7 +35,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('resourceInfo')}
{t("resourceInfo")}
</AlertTitle>
<AlertDescription className="mt-4">
<InfoSections cols={4}>
@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<>
<InfoSection>
<InfoSectionTitle>
{t('authentication')}
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authInfo.password ||
@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
authInfo.whitelist ? (
<div className="flex items-start space-x-2 text-green-500">
<ShieldCheck className="w-4 h-4 mt-0.5" />
<span>{t('protected')}</span>
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2 text-yellow-500">
<ShieldOff className="w-4 h-4" />
<span>{t('notProtected')}</span>
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
<InfoSectionTitle>{t("site")}</InfoSectionTitle>
<InfoSectionContent>
{resource.siteName}
</InfoSectionContent>
@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
) : (
<>
<InfoSection>
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
<InfoSectionTitle>
{t("protocol")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.protocol.toUpperCase()}
@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={resource.proxyPort!.toString()}
@ -114,13 +117,29 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
/>
</InfoSectionContent>
</InfoSection>
{build == "oss" && (
<InfoSection>
<InfoSectionTitle>
{t("externalProxyEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enableProxy
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>
)}
</>
)}
<InfoSection>
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
<InfoSectionTitle>{t("visibility")}</InfoSectionTitle>
<InfoSectionContent>
<span>
{resource.enabled ? t('enabled') : t('disabled')}
{resource.enabled
? t("enabled")
: t("disabled")}
</span>
</InfoSectionContent>
</InfoSection>

View file

@ -66,6 +66,7 @@ import {
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Credenza,
CredenzaBody,
@ -78,6 +79,7 @@ import {
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
const TransferFormSchema = z.object({
siteId: z.number()
@ -118,25 +120,31 @@ export default function GeneralForm() {
fullDomain: string;
} | null>(null);
const GeneralFormSchema = z.object({
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
domainId: z.string().optional(),
proxyPort: z.number().int().min(1).max(65535).optional()
}).refine((data) => {
proxyPort: z.number().int().min(1).max(65535).optional(),
enableProxy: z.boolean().optional()
})
.refine(
(data) => {
// For non-HTTP resources, proxyPort should be defined
if (!resource.http) {
return data.proxyPort !== undefined;
}
// For HTTP resources, proxyPort should be undefined
return data.proxyPort === undefined;
}, {
},
{
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>;
@ -147,7 +155,8 @@ export default function GeneralForm() {
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
domainId: resource.domainId || undefined,
proxyPort: resource.proxyPort || undefined
proxyPort: resource.proxyPort || undefined,
enableProxy: resource.enableProxy || false
},
mode: "onChange"
});
@ -211,7 +220,8 @@ export default function GeneralForm() {
name: data.name,
subdomain: data.subdomain,
domainId: data.domainId,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
enableProxy: data.enableProxy
}
)
.catch((e) => {
@ -238,7 +248,8 @@ export default function GeneralForm() {
name: data.name,
subdomain: data.subdomain,
fullDomain: resource.fullDomain,
proxyPort: data.proxyPort
proxyPort: data.proxyPort,
enableProxy: data.enableProxy
});
router.refresh();
@ -357,16 +368,29 @@ export default function GeneralForm() {
render={({ field }) => (
<FormItem>
<FormLabel>
{t("resourcePortNumber")}
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) =>
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e.target.value
? parseInt(e.target.value)
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
@ -374,11 +398,49 @@ export default function GeneralForm() {
</FormControl>
<FormMessage />
<FormDescription>
{t("resourcePortNumberDescription")}
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</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 { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
@ -64,6 +65,7 @@ import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@ -78,7 +80,8 @@ const httpResourceFormSchema = z.object({
const tcpUdpResourceFormSchema = z.object({
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>;
@ -144,7 +147,8 @@ export default function Page() {
resolver: zodResolver(tcpUdpResourceFormSchema),
defaultValues: {
protocol: "tcp",
proxyPort: undefined
proxyPort: undefined,
enableProxy: false
}
});
@ -166,13 +170,14 @@ export default function Page() {
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
protocol: "tcp",
protocol: "tcp"
});
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
protocol: tcpUdpData.protocol,
proxyPort: tcpUdpData.proxyPort
proxyPort: tcpUdpData.proxyPort,
enableProxy: tcpUdpData.enableProxy
});
}
@ -198,8 +203,15 @@ export default function Page() {
if (isHttp) {
router.push(`/${orgId}/settings/resources/${id}`);
} else {
const tcpUdpData = tcpUdpForm.getValues();
// 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) {
@ -603,6 +615,46 @@ export default function Page() {
</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>
</SettingsSectionForm>