From bbaea4def050f4fe20d6ab1cd955d3fc2ab62730 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 18 Jul 2025 21:41:58 -0700 Subject: [PATCH 01/35] Handle peer relay dynamically now --- server/routers/olm/handleOlmRelayMessage.ts | 48 ++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 83a97a41..cefc5b91 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -1,7 +1,7 @@ -import { db } from "@server/db"; +import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "../ws"; import { clients, clientSites, Olm } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { updatePeer } from "../newt/peers"; import logger from "@server/logger"; @@ -30,29 +30,67 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { .limit(1); if (!client) { - logger.warn("Site not found or does not have exit node"); + logger.warn("Client not found"); return; } // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old if (!client.pubKey) { - logger.warn("Site or client has no endpoint or listen port"); + logger.warn("Client has no endpoint or listen port"); return; } const { siteId } = message.data; + // Get the site + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!site || !site.exitNodeId) { + logger.warn("Site not found or has no exit node"); + return; + } + + // get the site's exit node + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + + if (!exitNode) { + logger.warn("Exit node not found for site"); + return; + } + await db .update(clientSites) .set({ isRelayed: true }) - .where(eq(clientSites.clientId, olm.clientId)); + .where( + and( + eq(clientSites.clientId, olm.clientId), + eq(clientSites.siteId, siteId) + ) + ); // update the peer on the exit node await updatePeer(siteId, client.pubKey, { endpoint: "" // this removes the endpoint }); + sendToClient(olm.olmId, { + type: "olm/wg/peer/relay", + data: { + siteId: siteId, + endpoint: exitNode.endpoint, + publicKey: exitNode.publicKey + } + }); + return; }; From 86a4656651ea852d1e111f9c4fc98e486c1b2c15 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 19 Jul 2025 22:54:30 -0700 Subject: [PATCH 02/35] fix multi level subdomain conflict bug --- messages/en-US.json | 4 ++-- src/components/DomainPicker.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ff0ca4e6..0c3dd233 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1196,7 +1196,7 @@ "sidebarExpand": "Expand", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", - "domainPickerEnterDomain": "Enter your domain", + "domainPickerEnterDomain": "Domain", "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", @@ -1206,7 +1206,7 @@ "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Checking availability...", - "domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.", + "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check your organization's domain settings.", "domainPickerOrganizationDomains": "Organization Domains", "domainPickerProvidedDomains": "Provided Domains", "domainPickerSubdomain": "Subdomain: {subdomain}", diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 98ae6b6a..1b96ec8e 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -382,7 +382,7 @@ export default function DomainPicker({ - {t("domainPickerNoMatchingDomains", { userInput })} + {t("domainPickerNoMatchingDomains")} )} From d000879c011b5c9746e2d1c1220c0bb1d34cd44a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Jul 2025 12:42:50 -0700 Subject: [PATCH 03/35] Add config for domains --- server/lib/readConfigFile.ts | 22 ++++++++++++++++++++++ server/routers/domain/createOrgDomain.ts | 7 ++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f738b986..9ba21aa4 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -231,7 +231,29 @@ export const configSchema = z disable_config_managed_domains: z.boolean().optional(), enable_clients: z.boolean().optional() }) + .optional(), + dns: z + .object({ + nameservers: z + .array(z.string().url()) + .optional() + .default([ + "ns1.fossorial.io", + "ns2.fossorial.io", + ]), + cname_extension: z + .string() + .optional() + .default("fossorial.io"), + }) .optional() + .default({ + nameservers: [ + "ns1.fossorial.io", + "ns2.fossorial.io", + ], + cname_extension: "fossorial.io" + }), }) .refine( (data) => { diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index b401409b..3e84072f 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -11,6 +11,7 @@ import { generateId } from "@server/auth/sessions/app"; import { eq, and } from "drizzle-orm"; import { isValidDomain } from "@server/lib/validators"; import { build } from "@server/build"; +import config from "@server/lib/config"; const paramsSchema = z .object({ @@ -228,15 +229,15 @@ export async function createOrgDomain( // TODO: This needs to be cross region and not hardcoded if (type === "ns") { - nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"]; + nsRecords = config.getRawConfig().dns.nameservers; } else if (type === "cname") { cnameRecords = [ { - value: `${domainId}.cname.fossorial.io`, + value: `${domainId}.${config.getRawConfig().dns.cname_extension}`, baseDomain: baseDomain }, { - value: `_acme-challenge.${domainId}.cname.fossorial.io`, + value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`, baseDomain: `_acme-challenge.${baseDomain}` } ]; From 9f2710185bfaa130b3e09e46b83845546dbf08b0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 21 Jul 2025 13:10:34 -0700 Subject: [PATCH 04/35] center toast --- src/components/ui/toast.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index c723859c..26510e84 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef< Date: Mon, 21 Jul 2025 14:28:32 -0700 Subject: [PATCH 05/35] allow using password to log in if security keys are available --- server/lib/readConfigFile.ts | 21 +++++------------ server/routers/auth/login.ts | 30 ++++++++++++------------ server/routers/domain/createOrgDomain.ts | 2 +- 3 files changed, 22 insertions(+), 31 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 9ba21aa4..b136f61f 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -235,25 +235,16 @@ export const configSchema = z dns: z .object({ nameservers: z - .array(z.string().url()) + .array(z.string().optional().optional()) .optional() - .default([ - "ns1.fossorial.io", - "ns2.fossorial.io", - ]), - cname_extension: z - .string() - .optional() - .default("fossorial.io"), - }) + .default(["ns1.fossorial.io", "ns2.fossorial.io"]), + cname_extension: z.string().optional().default("fossorial.io") + }) .optional() .default({ - nameservers: [ - "ns1.fossorial.io", - "ns2.fossorial.io", - ], + nameservers: ["ns1.fossorial.io", "ns2.fossorial.io"], cname_extension: "fossorial.io" - }), + }) }) .refine( (data) => { diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index cd51e46a..8dad5a42 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -106,21 +106,21 @@ export async function login( ); } - // Check if user has security keys registered - const userSecurityKeys = await db - .select() - .from(securityKeys) - .where(eq(securityKeys.userId, existingUser.userId)); - - if (userSecurityKeys.length > 0) { - return response(res, { - data: { useSecurityKey: true }, - success: true, - error: false, - message: "Security key authentication required", - status: HttpCode.OK - }); - } + // // Check if user has security keys registered + // const userSecurityKeys = await db + // .select() + // .from(securityKeys) + // .where(eq(securityKeys.userId, existingUser.userId)); + // + // if (userSecurityKeys.length > 0) { + // return response(res, { + // data: { useSecurityKey: true }, + // success: true, + // error: false, + // message: "Security key authentication required", + // status: HttpCode.OK + // }); + // } if ( existingUser.twoFactorSetupRequested && diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 3e84072f..08718d44 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -229,7 +229,7 @@ export async function createOrgDomain( // TODO: This needs to be cross region and not hardcoded if (type === "ns") { - nsRecords = config.getRawConfig().dns.nameservers; + nsRecords = config.getRawConfig().dns.nameservers as string[]; } else if (type === "cname") { cnameRecords = [ { From f1bba3b958b22cc1f8e98b0d7302fb61ad2dcf78 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Jul 2025 16:32:02 -0700 Subject: [PATCH 06/35] Fix issues in pg schema --- server/db/pg/schema.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 39f14598..e256f28d 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -504,8 +504,8 @@ export const clients = pgTable("clients", { name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet").notNull(), - megabytesIn: integer("bytesIn"), - megabytesOut: integer("bytesOut"), + megabytesIn: real("bytesIn"), + megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastPing: varchar("lastPing"), type: varchar("type").notNull(), // "olm" @@ -539,7 +539,7 @@ export const olmSessions = pgTable("clientSession", { olmId: varchar("olmId") .notNull() .references(() => olms.olmId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull() + expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), }); export const userClients = pgTable("userClients", { From 114ce8997f31483602b973505d02a6ac3f5071ac Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 21 Jul 2025 16:56:47 -0700 Subject: [PATCH 07/35] add tos and pp consent --- messages/en-US.json | 8 ++- server/db/pg/schema.ts | 5 +- server/db/sqlite/schema.ts | 2 + server/routers/auth/signup.ts | 25 ++++++--- src/app/auth/signup/SignupForm.tsx | 88 +++++++++++++++++++++++++++--- 5 files changed, 112 insertions(+), 16 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0c3dd233..ed004d99 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1274,5 +1274,11 @@ "createDomainDnsPropagation": "DNS Propagation", "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", "resourcePortRequired": "Port number is required for non-HTTP resources", - "resourcePortNotAllowed": "Port number should not be set for HTTP resources" + "resourcePortNotAllowed": "Port number should not be set for HTTP resources", + "signUpTerms": { + "IAgreeToThe": "I agree to the", + "termsOfService": "terms of service", + "and": "and", + "privacyPolicy": "privacy policy" + } } diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index e256f28d..77be5f1b 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -5,7 +5,8 @@ import { boolean, integer, bigint, - real + real, + text } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; @@ -135,6 +136,8 @@ export const users = pgTable("user", { twoFactorSecret: varchar("twoFactorSecret"), emailVerified: boolean("emailVerified").notNull().default(false), dateCreated: varchar("dateCreated").notNull(), + termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), + termsVersion: varchar("termsVersion"), serverAdmin: boolean("serverAdmin").notNull().default(false) }); diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 3e442d07..2c44b593 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -154,6 +154,8 @@ export const users = sqliteTable("user", { .notNull() .default(false), dateCreated: text("dateCreated").notNull(), + termsAcceptedTimestamp: text("termsAcceptedTimestamp"), + termsVersion: text("termsVersion"), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false) diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 2508ecfe..09c8db07 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -21,15 +21,14 @@ import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; +import { build } from "@server/build"; export const signupBodySchema = z.object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), password: passwordSchema, inviteToken: z.string().optional(), - inviteId: z.string().optional() + inviteId: z.string().optional(), + termsAcceptedTimestamp: z.string().nullable().optional() }); export type SignUpBody = z.infer; @@ -54,7 +53,8 @@ export async function signup( ); } - const { email, password, inviteToken, inviteId } = parsedBody.data; + const { email, password, inviteToken, inviteId, termsAcceptedTimestamp } = + parsedBody.data; const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -161,13 +161,24 @@ export async function signup( } } + if (build === "saas" && !termsAcceptedTimestamp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "You must accept the terms of service and privacy policy" + ) + ); + } + await db.insert(users).values({ userId: userId, type: UserType.Internal, username: email, email: email, passwordHash, - dateCreated: moment().toISOString() + dateCreated: moment().toISOString(), + termsAcceptedTimestamp: termsAcceptedTimestamp || null, + termsVersion: "1" }); // give the user their default permissions: diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index c6ed500b..5494ba10 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -6,6 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -33,6 +34,7 @@ import Image from "next/image"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { useTranslations } from "next-intl"; import BrandingLogo from "@app/components/BrandingLogo"; +import { build } from "@server/build"; type SignupFormProps = { redirect?: string; @@ -44,7 +46,19 @@ const formSchema = z .object({ email: z.string().email({ message: "Invalid email address" }), password: passwordSchema, - confirmPassword: passwordSchema + confirmPassword: passwordSchema, + agreeToTerms: z.boolean().refine( + (val) => { + if (build === "saas") { + val === true; + } + return true; + }, + { + message: + "You must agree to the terms of service and privacy policy" + } + ) }) .refine((data) => data.password === data.confirmPassword, { path: ["confirmPassword"], @@ -64,13 +78,15 @@ export default function SignupForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [termsAgreedAt, setTermsAgreedAt] = useState(null); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { email: "", password: "", - confirmPassword: "" + confirmPassword: "", + agreeToTerms: false } }); @@ -85,7 +101,8 @@ export default function SignupForm({ email, password, inviteId, - inviteToken + inviteToken, + termsAcceptedTimestamp: termsAgreedAt }) .catch((e) => { console.error(e); @@ -120,14 +137,23 @@ export default function SignupForm({ return t("authCreateAccount"); } + const handleTermsChange = (checked: boolean) => { + if (checked) { + const isoNow = new Date().toISOString(); + console.log("Terms agreed at:", isoNow); + setTermsAgreedAt(isoNow); + form.setValue("agreeToTerms", true); + } else { + form.setValue("agreeToTerms", false); + setTermsAgreedAt(null); + } + }; + return (
- +

{getSubtitle()}

@@ -180,6 +206,54 @@ export default function SignupForm({ )} /> + {build === "saas" && ( + ( + + + { + field.onChange(checked); + handleTermsChange( + checked as boolean + ); + }} + /> + +
+ + {t("signUpTerms.IAgreeToThe")} + + {t( + "signUpTerms.termsOfService" + )} + + {t("signUpTerms.and")} + + {t( + "signUpTerms.privacyPolicy" + )} + + + +
+
+ )} + /> + )} {error && ( From b54ccbfa2fde0d8bed383ace93db3c4b5f136321 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 21 Jul 2025 17:26:02 -0700 Subject: [PATCH 08/35] fix log in loading button --- src/components/LoginForm.tsx | 183 +++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 71 deletions(-) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 153b7eb7..ddd410e2 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -63,7 +63,6 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [securityKeyLoading, setSecurityKeyLoading] = useState(false); const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); @@ -72,14 +71,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const t = useTranslations(); const formSchema = z.object({ - email: z.string().email({ message: t('emailInvalid') }), - password: z - .string() - .min(8, { message: t('passwordRequirementsChars') }) + email: z.string().email({ message: t("emailInvalid") }), + password: z.string().min(8, { message: t("passwordRequirementsChars") }) }); const mfaSchema = z.object({ - code: z.string().length(6, { message: t('pincodeInvalid') }) + code: z.string().length(6, { message: t("pincodeInvalid") }) }); const form = useForm>({ @@ -99,17 +96,23 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { async function initiateSecurityKeyAuth() { setShowSecurityKeyPrompt(true); - setSecurityKeyLoading(true); + setLoading(true); setError(null); try { // Start WebAuthn authentication without email - const startRes = await api.post("/auth/security-key/authenticate/start", {}); + const startRes = await api.post( + "/auth/security-key/authenticate/start", + {} + ); if (!startRes) { - setError(t('securityKeyAuthError', { - defaultValue: "Failed to start security key authentication" - })); + setError( + t("securityKeyAuthError", { + defaultValue: + "Failed to start security key authentication" + }) + ); return; } @@ -125,7 +128,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { { credential }, { headers: { - 'X-Temp-Session-Id': tempSessionId + "X-Temp-Session-Id": tempSessionId } } ); @@ -136,39 +139,61 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } } catch (error: any) { - if (error.name === 'NotAllowedError') { - if (error.message.includes('denied permission')) { - setError(t('securityKeyPermissionDenied', { - defaultValue: "Please allow access to your security key to continue signing in." - })); + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { + setError( + t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue signing in." + }) + ); } else { - setError(t('securityKeyRemovedTooQuickly', { - defaultValue: "Please keep your security key connected until the sign-in process completes." - })); + setError( + t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the sign-in process completes." + }) + ); } - } else if (error.name === 'NotSupportedError') { - setError(t('securityKeyNotSupported', { - defaultValue: "Your security key may not be compatible. Please try a different security key." - })); + } else if (error.name === "NotSupportedError") { + setError( + t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." + }) + ); } else { - setError(t('securityKeyUnknownError', { - defaultValue: "There was a problem using your security key. Please try again." - })); + setError( + t("securityKeyUnknownError", { + defaultValue: + "There was a problem using your security key. Please try again." + }) + ); } } } catch (e: any) { if (e.isAxiosError) { - setError(formatAxiosError(e, t('securityKeyAuthError', { - defaultValue: "Failed to authenticate with security key" - }))); + setError( + formatAxiosError( + e, + t("securityKeyAuthError", { + defaultValue: + "Failed to authenticate with security key" + }) + ) + ); } else { console.error(e); - setError(e.message || t('securityKeyAuthError', { - defaultValue: "Failed to authenticate with security key" - })); + setError( + e.message || + t("securityKeyAuthError", { + defaultValue: + "Failed to authenticate with security key" + }) + ); } } finally { - setSecurityKeyLoading(false); + setLoading(false); setShowSecurityKeyPrompt(false); } } @@ -182,11 +207,14 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { setShowSecurityKeyPrompt(false); try { - const res = await api.post>("/auth/login", { - email, - password, - code - }); + const res = await api.post>( + "/auth/login", + { + email, + password, + code + } + ); const data = res.data.data; @@ -212,7 +240,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } if (data?.twoFactorSetupRequired) { - const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`; + const setupUrl = `/auth/2fa/setup?email=${encodeURIComponent(email)}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""}`; router.push(setupUrl); return; } @@ -222,16 +250,22 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { } } catch (e: any) { if (e.isAxiosError) { - const errorMessage = formatAxiosError(e, t('loginError', { - defaultValue: "Failed to log in" - })); + const errorMessage = formatAxiosError( + e, + t("loginError", { + defaultValue: "Failed to log in" + }) + ); setError(errorMessage); return; } else { console.error(e); - setError(e.message || t('loginError', { - defaultValue: "Failed to log in" - })); + setError( + e.message || + t("loginError", { + defaultValue: "Failed to log in" + }) + ); return; } } finally { @@ -251,7 +285,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { console.log(res); if (!res) { - setError(t('loginError')); + setError(t("loginError")); return; } @@ -268,8 +302,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { - {t('securityKeyPrompt', { - defaultValue: "Please verify your identity using your security key. Make sure your security key is connected and ready." + {t("securityKeyPrompt", { + defaultValue: + "Please verify your identity using your security key. Make sure your security key is connected and ready." })} @@ -288,7 +323,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { name="email" render={({ field }) => ( - {t('email')} + {t("email")} @@ -303,7 +338,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { name="password" render={({ field }) => ( - {t('password')} + + {t("password")} + - {t('passwordForgot')} + {t("passwordForgot")}
-
@@ -342,11 +379,9 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { {mfaRequested && ( <>
-

- {t('otpAuth')} -

+

{t("otpAuth")}

- {t('otpAuthDescription')} + {t("otpAuthDescription")}

@@ -368,10 +403,16 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { pattern={ REGEXP_ONLY_DIGITS_AND_CHARS } - onChange={(value: string) => { + onChange={( + value: string + ) => { field.onChange(value); - if (value.length === 6) { - mfaForm.handleSubmit(onSubmit)(); + if ( + value.length === 6 + ) { + mfaForm.handleSubmit( + onSubmit + )(); } }} > @@ -422,7 +463,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { loading={loading} disabled={loading} > - {t('otpAuthSubmit')} + {t("otpAuthSubmit")} )} @@ -433,11 +474,11 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { variant="outline" className="w-full" onClick={initiateSecurityKeyAuth} - loading={securityKeyLoading} - disabled={securityKeyLoading || showSecurityKeyPrompt} + loading={loading} + disabled={loading || showSecurityKeyPrompt} > - {t('securityKeyLogin', { + {t("securityKeyLogin", { defaultValue: "Sign in with security key" })} @@ -450,7 +491,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
- {t('idpContinue')} + {t("idpContinue")}
@@ -483,7 +524,7 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { mfaForm.reset(); }} > - {t('otpAuthBack')} + {t("otpAuthBack")} )} From 5c929badeb209c5aab677c6e662574e4deff5811 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Jul 2025 11:21:39 -0700 Subject: [PATCH 09/35] Send endpoint --- server/routers/olm/handleOlmRegisterMessage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 9f626a7b..cf4ad8b7 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -58,7 +58,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { sendToClient(olm.olmId, { type: "olm/wg/holepunch", data: { - serverPubKey: exitNode.publicKey + serverPubKey: exitNode.publicKey, + endpoint: exitNode.endpoint, } }); } From 52d46f98794650b25b0e7bb2f6019701f45cc09a Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 21 Jul 2025 13:39:39 +0200 Subject: [PATCH 10/35] add nixos option for newt in site creation --- .../[orgId]/settings/sites/create/page.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 454f609e..9b81fc9b 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -42,6 +42,7 @@ import { FaFreebsd, FaWindows } from "react-icons/fa"; +import { SiNixos } from "react-icons/si"; import { Checkbox } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { generateKeypair } from "../[niceId]/wireguardConfig"; @@ -74,6 +75,7 @@ type Commands = { windows: Record; docker: Record; podman: Record; + nixos: Record; }; const platforms = [ @@ -82,7 +84,8 @@ const platforms = [ "podman", "mac", "windows", - "freebsd" + "freebsd", + "nixos" ] as const; type Platform = (typeof platforms)[number]; @@ -285,6 +288,14 @@ WantedBy=default.target` "Podman Run": [ `podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}` ] + }, + nixos: { + x86_64: [ + `nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + aarch64: [ + `nix run 'nixpkgs#fosrl-newt' --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] } }; setCommands(commands); @@ -304,6 +315,8 @@ WantedBy=default.target` return ["Podman Quadlet", "Podman Run"]; case "freebsd": return ["amd64", "arm64"]; + case "nixos": + return ["x86_64", "aarch64"]; default: return ["x64"]; } @@ -321,6 +334,8 @@ WantedBy=default.target` return "Podman"; case "freebsd": return "FreeBSD"; + case "nixos": + return "NixOS"; default: return "Linux"; } @@ -365,6 +380,8 @@ WantedBy=default.target` return ; case "freebsd": return ; + case "nixos": + return ; default: return ; } From bcc2c59f08b0803c03cda25dc115803c03c97aaf Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:04:55 +0800 Subject: [PATCH 11/35] Add member portal functionality - extracted from feature/member-landing-page --- server/routers/external.ts | 6 + server/routers/resource/getUserResources.ts | 168 +++++ server/routers/resource/index.ts | 3 +- src/app/[orgId]/MemberResourcesPortal.tsx | 732 ++++++++++++++++++++ src/app/[orgId]/page.tsx | 31 +- src/app/navigation.tsx | 24 +- 6 files changed, 938 insertions(+), 26 deletions(-) create mode 100644 server/routers/resource/getUserResources.ts create mode 100644 src/app/[orgId]/MemberResourcesPortal.tsx diff --git a/server/routers/external.ts b/server/routers/external.ts index 6f0b04dc..5bae553e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -233,6 +233,12 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/user-resources", + verifyOrgAccess, + resource.getUserResources +); + authenticated.get( "/org/:orgId/domains", verifyOrgAccess, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts new file mode 100644 index 00000000..681ec4d0 --- /dev/null +++ b/server/routers/resource/getUserResources.ts @@ -0,0 +1,168 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { and, eq, or, inArray } from "drizzle-orm"; +import { + resources, + userResources, + roleResources, + userOrgs, + roles, + resourcePassword, + resourcePincode, + resourceWhitelist, + sites +} from "@server/db"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { response } from "@server/lib/response"; + +export async function getUserResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { orgId } = req.params; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // First get the user's role in the organization + const userOrgResult = await db + .select({ + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, orgId) + ) + ) + .limit(1); + + if (userOrgResult.length === 0) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User not in organization") + ); + } + + const userRoleId = userOrgResult[0].roleId; + + // Get resources accessible through direct assignment or role assignment + const directResourcesQuery = db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .where(eq(userResources.userId, userId)); + + const roleResourcesQuery = db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(eq(roleResources.roleId, userRoleId)); + + const [directResources, roleResourceResults] = await Promise.all([ + directResourcesQuery, + roleResourcesQuery + ]); + + // Combine all accessible resource IDs + const accessibleResourceIds = [ + ...directResources.map(r => r.resourceId), + ...roleResourceResults.map(r => r.resourceId) + ]; + + if (accessibleResourceIds.length === 0) { + return response(res, { + data: { resources: [] }, + success: true, + error: false, + message: "No resources found", + status: HttpCode.OK + }); + } + + // Get resource details for accessible resources + const resourcesData = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + siteName: sites.name + }) + .from(resources) + .leftJoin(sites, eq(sites.siteId, resources.siteId)) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); + + // Check for password, pincode, and whitelist protection for each resource + const resourcesWithAuth = await Promise.all( + resourcesData.map(async (resource) => { + const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ + db.select().from(resourcePassword).where(eq(resourcePassword.resourceId, resource.resourceId)).limit(1), + db.select().from(resourcePincode).where(eq(resourcePincode.resourceId, resource.resourceId)).limit(1), + db.select().from(resourceWhitelist).where(eq(resourceWhitelist.resourceId, resource.resourceId)).limit(1) + ]); + + const hasPassword = passwordCheck.length > 0; + const hasPincode = pincodeCheck.length > 0; + const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; + + return { + resourceId: resource.resourceId, + name: resource.name, + domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, + enabled: resource.enabled, + protected: !!(resource.sso || hasPassword || hasPincode || hasWhitelist), + protocol: resource.protocol, + sso: resource.sso, + password: hasPassword, + pincode: hasPincode, + whitelist: hasWhitelist, + siteName: resource.siteName + }; + }) + ); + + return response(res, { + data: { resources: resourcesWithAuth }, + success: true, + error: false, + message: "User resources retrieved successfully", + status: HttpCode.OK + }); + + } catch (error) { + console.error("Error fetching user resources:", error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Internal server error") + ); + } +} + +export type GetUserResourcesResponse = { + success: boolean; + data: { + resources: Array<{ + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + }>; + }; +}; \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 03c9ffbe..f97fcdf4 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,4 +21,5 @@ export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; -export * from "./updateResourceRule"; \ No newline at end of file +export * from "./updateResourceRule"; +export * from "./getUserResources"; \ No newline at end of file diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx new file mode 100644 index 00000000..142d5516 --- /dev/null +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -0,0 +1,732 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight, Building2, Key, KeyRound, Fingerprint, AtSign, Copy, InfoIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useToast } from "@app/hooks/useToast"; + +// Update Resource type to include site information +type Resource = { + resourceId: number; + name: string; + domain: string; + enabled: boolean; + protected: boolean; + protocol: string; + // Auth method fields + sso?: boolean; + password?: boolean; + pincode?: boolean; + whitelist?: boolean; + // Site information + siteName?: string | null; +}; + +type MemberResourcesPortalProps = { + orgId: string; +}; + +// Favicon component with fallback +const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean }) => { + const [faviconError, setFaviconError] = useState(false); + const [faviconLoaded, setFaviconLoaded] = useState(false); + + // Extract domain for favicon URL + const cleanDomain = domain.replace(/^https?:\/\//, '').split('/')[0]; + const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; + + const handleFaviconLoad = () => { + setFaviconLoaded(true); + setFaviconError(false); + }; + + const handleFaviconError = () => { + setFaviconError(true); + setFaviconLoaded(false); + }; + + if (faviconError || !enabled) { + return ; + } + + return ( +
+ {!faviconLoaded && ( +
+ )} + {`${cleanDomain} +
+ ); +}; + +// Enhanced status badge component +const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => { + if (!enabled) { + return ( + + + +
+
+
+
+ +

Resource Disabled

+
+
+
+ ); + } + + if (isProtected) { + return ( + + + +
+ +
+
+ +

Protected Resource

+
+

Authentication Methods:

+
+ {resource.sso && ( +
+
+ +
+ Single Sign-On (SSO) +
+ )} + {resource.password && ( +
+
+ +
+ Password Protected +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+
+
+
+ ); + } + + return ( +
+ +
+ ); +}; + +// Resource Info component +const ResourceInfo = ({ resource }: { resource: Resource }) => { + const hasAuthMethods = resource.sso || resource.password || resource.pincode || resource.whitelist; + + return ( + + + +
+ +
+
+ + {/* Site Information */} + {resource.siteName && ( +
+
Site
+
+ + {resource.siteName} +
+
+ )} + + {/* Authentication Methods */} + {hasAuthMethods && ( +
+
Authentication Methods
+
+ {resource.sso && ( +
+
+ +
+ Single Sign-On (SSO) +
+ )} + {resource.password && ( +
+
+ +
+ Password Protected +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+ )} + + {/* Resource Status - if disabled */} + {!resource.enabled && ( +
+
+ + Resource Disabled +
+
+ )} +
+
+
+ ); +}; + +// Site badge component +const SiteBadge = ({ resource }: { resource: Resource }) => { + if (!resource.siteName) { + return null; + } + + return ( + + + +
+ +
+
+ +

{resource.siteName}

+
+
+
+ ); +}; + +// Pagination component +const PaginationControls = ({ + currentPage, + totalPages, + onPageChange, + totalItems, + itemsPerPage +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; + totalItems: number; + itemsPerPage: number; +}) => { + const startItem = (currentPage - 1) * itemsPerPage + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalItems); + + if (totalPages <= 1) return null; + + return ( +
+
+ Showing {startItem}-{endItem} of {totalItems} resources +
+ +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + // Show first page, last page, current page, and 2 pages around current + const showPage = + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1; + + const showEllipsis = + (page === 2 && currentPage > 4) || + (page === totalPages - 1 && currentPage < totalPages - 3); + + if (!showPage && !showEllipsis) return null; + + if (showEllipsis) { + return ( + + ... + + ); + } + + return ( + + ); + })} +
+ + +
+
+ ); +}; + +// Loading skeleton component +const ResourceCardSkeleton = () => ( + + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { toast } = useToast(); + + const [resources, setResources] = useState([]); + const [filteredResources, setFilteredResources] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("name-asc"); + const [refreshing, setRefreshing] = useState(false); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 12; // 3x4 grid on desktop + + const fetchUserResources = async (isRefresh = false) => { + try { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const response = await api.get( + `/org/${orgId}/user-resources` + ); + + if (response.data.success) { + setResources(response.data.data.resources); + setFilteredResources(response.data.data.resources); + } else { + setError("Failed to load resources"); + } + } catch (err) { + console.error("Error fetching user resources:", err); + setError("Failed to load resources. Please check your connection and try again."); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchUserResources(); + }, [orgId, api]); + + // Filter and sort resources + useEffect(() => { + let filtered = resources.filter(resource => + resource.name.toLowerCase().includes(searchQuery.toLowerCase()) || + resource.domain.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort resources + filtered.sort((a, b) => { + switch (sortBy) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "domain-asc": + return a.domain.localeCompare(b.domain); + case "domain-desc": + return b.domain.localeCompare(a.domain); + case "status-enabled": + // Enabled first, then protected vs unprotected + if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; + return b.protected ? 1 : -1; + case "status-disabled": + // Disabled first, then unprotected vs protected + if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; + return a.protected ? 1 : -1; + default: + return a.name.localeCompare(b.name); + } + }); + + setFilteredResources(filtered); + + // Reset to first page when search/sort changes + setCurrentPage(1); + }, [resources, searchQuery, sortBy]); + + // Calculate pagination + const totalPages = Math.ceil(filteredResources.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const paginatedResources = filteredResources.slice(startIndex, startIndex + itemsPerPage); + + const handleOpenResource = (resource: Resource) => { + // Open the resource in a new tab + window.open(resource.domain, '_blank'); + }; + + const handleRefresh = () => { + fetchUserResources(true); + }; + + const handleRetry = () => { + fetchUserResources(); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + if (loading) { + return ( +
+ + + {/* Search and Sort Controls - Skeleton */} +
+
+
+
+
+
+
+
+ + {/* Loading Skeletons */} +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + +
+ +
+

+ Unable to Load Resources +

+

+ {error} +

+ +
+
+
+ ); + } + + return ( +
+ + + {/* Search and Sort Controls with Refresh */} +
+
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full pl-8" + /> + +
+ + {/* Sort */} +
+ +
+
+ + {/* Refresh Button */} + +
+ + {/* Resources Content */} + {filteredResources.length === 0 ? ( + /* Enhanced Empty State */ + + +
+ {searchQuery ? ( + + ) : ( + + )} +
+

+ {searchQuery ? "No Resources Found" : "No Resources Available"} +

+

+ {searchQuery + ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` + : "You don't have access to any resources yet. Contact your administrator to get access to resources you need." + } +

+
+ {searchQuery ? ( + + ) : ( + + )} +
+
+
+ ) : ( + <> + {/* Resources Grid */} +
+ {paginatedResources.map((resource) => ( + +
+
+
+
+ +
+ + + + + {resource.name} + + + +

{resource.name}

+
+
+
+
+ +
+ +
+
+ +
+ + +
+
+ +
+ +
+
+ ))} +
+ + {/* Pagination Controls */} + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index d19a6dcc..9a1dda94 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,6 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; import OrganizationLandingCard from "./OrganizationLandingCard"; +import MemberResourcesPortal from "./MemberResourcesPortal"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -9,6 +10,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { pullEnv } from "@app/lib/pullEnv"; +import EnvProvider from "@app/providers/EnvProvider"; +import { orgLangingNavItems } from "@app/app/navigation"; type OrgPageProps = { params: Promise<{ orgId: string }>; @@ -17,6 +21,7 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; + const env = pullEnv(); const getUser = cache(verifySession); const user = await getUser(); @@ -25,7 +30,6 @@ export default async function OrgPage(props: OrgPageProps) { redirect("/"); } - let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { const res = await internal.get>( @@ -33,16 +37,14 @@ export default async function OrgPage(props: OrgPageProps) { await authCookieHeader() ); overview = res.data.data; - - if (overview.isAdmin || overview.isOwner) { - redirectToSettings = true; - } } catch (e) {} - if (redirectToSettings) { + // If user is admin or owner, redirect to settings + if (overview?.isAdmin || overview?.isOwner) { redirect(`/${orgId}/settings`); } + // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -61,21 +63,8 @@ export default async function OrgPage(props: OrgPageProps) { {overview && ( -
- +
+
)} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index a18659f2..b8f60abd 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -12,15 +12,31 @@ import { KeyRound, TicketCheck, User, - Globe, - MonitorUp + Globe, // Added from 'dev' branch + MonitorUp // Added from 'dev' branch } from "lucide-react"; -export type SidebarNavSection = { +export type SidebarNavSection = { // Added from 'dev' branch heading: string; items: SidebarNavItem[]; }; +// Merged from 'user-management-and-resources' branch +export const orgLangingNavItems: SidebarNavItem[] = [ + { + title: "sidebarAccount", + href: "/{orgId}", + icon: , + autoExpand: true, + children: [ + { + title: "sidebarResources", + href: "/{orgId}" + } + ] + } +]; + export const orgNavSections = ( enableClients: boolean = true ): SidebarNavSection[] => [ @@ -125,4 +141,4 @@ export const adminNavSections: SidebarNavSection[] = [ : []) ] } -]; +]; \ No newline at end of file From 63494065232f61b479a9f937e55315e9853b44c7 Mon Sep 17 00:00:00 2001 From: Adrian Astles <49412215+adrianeastles@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:30:20 +0800 Subject: [PATCH 12/35] Removed member resouce sidebar to work with new sidebar. --- src/app/navigation.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index b8f60abd..9901ee2f 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -26,14 +26,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [ { title: "sidebarAccount", href: "/{orgId}", - icon: , - autoExpand: true, - children: [ - { - title: "sidebarResources", - href: "/{orgId}" - } - ] + icon: } ]; From 59cb06acf47417b2a17d1bb5569e3030b380363f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Jul 2025 14:48:24 -0700 Subject: [PATCH 13/35] Support relaying on register --- .../routers/olm/handleOlmRegisterMessage.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index cf4ad8b7..f504ecd7 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, ExitNode } from "@server/db"; import { MessageHandler } from "../ws"; import { clients, @@ -28,7 +28,10 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } const clientId = olm.clientId; - const { publicKey } = message.data; + const { publicKey, relay } = message.data; + + logger.debug(`Olm client ID: ${clientId}, Public Key: ${publicKey}, Relay: ${relay}`); + if (!publicKey) { logger.warn("Public key not provided"); return; @@ -62,6 +65,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { endpoint: exitNode.endpoint, } }); + } if (now - (client.lastHolePunch || 0) > 6) { @@ -85,7 +89,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await db .update(clientSites) .set({ - isRelayed: false + isRelayed: relay == true }) .where(eq(clientSites.clientId, olm.clientId)); } @@ -98,8 +102,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { .where(eq(clientSites.clientId, client.clientId)); // Prepare an array to store site configurations - const siteConfigurations = []; - + let siteConfigurations = []; + logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`); // Process each site for (const { sites: site } of sitesData) { if (!site.exitNodeId) { @@ -115,7 +119,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { continue; } - if (site.lastHolePunch && now - site.lastHolePunch > 6) { + if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { logger.warn( `Site ${site.siteId} last hole punch is too old, skipping` ); @@ -143,7 +147,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await addPeer(site.siteId, { publicKey: publicKey, allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client - endpoint: client.endpoint + endpoint: relay ? "" : client.endpoint }); } else { logger.warn( @@ -151,10 +155,24 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); } + let endpoint = site.endpoint; + if (relay) { + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + if (!exitNode) { + logger.warn(`Exit node not found for site ${site.siteId}`); + continue; + } + endpoint = `${exitNode.endpoint}:21820`; + } + // Add site configuration to the array siteConfigurations.push({ siteId: site.siteId, - endpoint: site.endpoint, + endpoint: endpoint, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort From 5f75813e84ea05078ca29ea191755d0bd97e2c7f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Jul 2025 20:47:39 -0700 Subject: [PATCH 14/35] Handle relaying change values in gerbil --- server/routers/gerbil/updateHolePunch.ts | 40 +++---- server/routers/newt/handleGetConfigMessage.ts | 100 +++++++++++++++--- 2 files changed, 104 insertions(+), 36 deletions(-) diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index c48f7551..e99225fe 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -45,7 +45,6 @@ export async function updateHolePunch( const { olmId, newtId, ip, port, timestamp, token } = parsedParams.data; - let currentSiteId: number | undefined; let destinations: PeerDestination[] = []; @@ -174,28 +173,29 @@ export async function updateHolePunch( } // Find all clients that connect to this site - const sitesClientPairs = await db - .select() - .from(clientSites) - .where(eq(clientSites.siteId, newt.siteId)); + // const sitesClientPairs = await db + // .select() + // .from(clientSites) + // .where(eq(clientSites.siteId, newt.siteId)); + // THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING // Get client details for each client - for (const pair of sitesClientPairs) { - const [client] = await db - .select() - .from(clients) - .where(eq(clients.clientId, pair.clientId)); + // for (const pair of sitesClientPairs) { + // const [client] = await db + // .select() + // .from(clients) + // .where(eq(clients.clientId, pair.clientId)); - if (client && client.endpoint) { - const [host, portStr] = client.endpoint.split(':'); - if (host && portStr) { - destinations.push({ - destinationIP: host, - destinationPort: parseInt(portStr, 10) - }); - } - } - } + // if (client && client.endpoint) { + // const [host, portStr] = client.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: parseInt(portStr, 10) + // }); + // } + // } + // } // If this is a newt/site, also add other sites in the same org // if (updatedSite.orgId) { diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 8d79d4fd..ce887b98 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -2,10 +2,11 @@ import { z } from "zod"; import { MessageHandler } from "../ws"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { db } from "@server/db"; +import { db, ExitNode, exitNodes } from "@server/db"; import { clients, clientSites, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { updatePeer } from "../olm/peers"; +import axios from "axios"; const inputSchema = z.object({ publicKey: z.string(), @@ -54,7 +55,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { logger.warn("handleGetConfigMessage: Site not found"); return; } - + // we need to wait for hole punch success if (!existingSite.endpoint) { logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`); @@ -87,6 +88,48 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { return; } + let exitNode: ExitNode | undefined; + if (site.exitNodeId) { + [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + if (exitNode.reachableAt) { + try { + const response = await axios.post( + `${exitNode.reachableAt}/update-proxy-mapping`, + { + oldDestination: { + destinationIP: existingSite.subnet?.split("/")[0], + destinationPort: existingSite.listenPort + }, + newDestination: { + destinationIP: site.subnet?.split("/")[0], + destinationPort: site.listenPort + } + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error( + `Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}` + ); + } + throw error; + } + } + } + // Get all clients connected to this site const clientsRes = await db .select() @@ -107,33 +150,58 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (!client.clients.endpoint) { return false; } - if (!client.clients.online) { - return false; - } - return true; }) .map(async (client) => { // Add or update this peer on the olm if it is connected try { - if (site.endpoint && site.publicKey) { - await updatePeer(client.clients.clientId, { - siteId: site.siteId, - endpoint: site.endpoint, - publicKey: site.publicKey, - serverIP: site.address, - serverPort: site.listenPort - }); + if (!site.publicKey) { + logger.warn( + `Site ${site.siteId} has no public key, skipping` + ); + return null; } + let endpoint = site.endpoint; + if (client.clientSites.isRelayed) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} has no exit node, skipping` + ); + return null; + } + + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId}` + ); + return null; + } + endpoint = `${exitNode.endpoint}:21820`; + } + + if (!endpoint) { + logger.warn( + `Site ${site.siteId} has no endpoint, skipping` + ); + return null; + } + + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); } catch (error) { logger.error( - `Failed to add/update peer ${client.clients.pubKey} to newt ${newt.newtId}: ${error}` + `Failed to add/update peer ${client.clients.pubKey} to olm ${newt.newtId}: ${error}` ); } return { publicKey: client.clients.pubKey!, - allowedIps: [`${client.clients.subnet.split('/')[0]}/32`], // we want to only allow from that client + allowedIps: [`${client.clients.subnet.split("/")[0]}/32`], // we want to only allow from that client endpoint: client.clientSites.isRelayed ? "" : client.clients.endpoint! // if its relayed it should be localhost From 760fe3aca98b1b4b6f0a7eb9a59c00acfe98ecb1 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Jul 2025 21:26:02 -0700 Subject: [PATCH 15/35] Create client component done --- messages/en-US.json | 29 +- .../[orgId]/settings/clients/ClientsTable.tsx | 65 +- .../[orgId]/settings/clients/create/page.tsx | 711 ++++++++++++++++++ 3 files changed, 766 insertions(+), 39 deletions(-) create mode 100644 src/app/[orgId]/settings/clients/create/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index ed004d99..df2d9799 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1280,5 +1280,30 @@ "termsOfService": "terms of service", "and": "and", "privacyPolicy": "privacy policy" - } -} + }, + "siteRequired": "Site is required.", + "olmTunnel": "Olm Tunnel", + "olmTunnelDescription": "Use Olm for client connectivity", + "errorCreatingClient": "Error creating client", + "clientDefaultsNotFound": "Client defaults not found", + "createClient": "Create Client", + "createClientDescription": "Create a new client for connecting to your sites", + "seeAllClients": "See All Clients", + "clientInformation": "Client Information", + "clientNamePlaceholder": "Client name", + "clientNameDescription": "A friendly name for this client", + "address": "Address", + "subnetPlaceholder": "Subnet", + "addressDescription": "The address that this client will use for connectivity", + "selectSites": "Select sites", + "sitesDescription": "The client will have connectivity to the selected sites", + "clientInstallOlm": "Install Olm", + "clientInstallOlmDescription": "Get Olm running on your system", + "clientOlmCredentials": "Olm Credentials", + "clientOlmCredentialsDescription": "This is how Olm will authenticate with the server", + "olmEndpoint": "Olm Endpoint", + "olmId": "Olm ID", + "olmSecretKey": "Olm Secret Key", + "clientCredentialsSave": "Save Your Credentials", + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place." +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/app/[orgId]/settings/clients/ClientsTable.tsx index 35ded645..90f04ca8 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/app/[orgId]/settings/clients/ClientsTable.tsx @@ -76,42 +76,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const clientRow = row.original; - const router = useRouter(); - - return ( - - - - - - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -243,6 +207,33 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { const clientRow = row.original; return (
+ + + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + + @@ -309,7 +300,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { columns={columns} data={rows} addClient={() => { - setIsCreateModalOpen(true); + router.push(`/${orgId}/settings/clients/create`) }} /> diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx new file mode 100644 index 00000000..850504f5 --- /dev/null +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -0,0 +1,711 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { createElement, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon, Terminal } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import CopyTextBox from "@app/components/CopyTextBox"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { + FaApple, + FaCubes, + FaDocker, + FaFreebsd, + FaWindows +} from "react-icons/fa"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + CreateClientBody, + CreateClientResponse, + PickClientDefaultsResponse +} from "@server/routers/client"; +import { ListSitesResponse } from "@server/routers/site"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; + +import { useTranslations } from "next-intl"; + +type ClientType = "olm"; + +interface TunnelTypeOption { + id: ClientType; + title: string; + description: string; + disabled?: boolean; +} + +type Commands = { + mac: Record; + linux: Record; + windows: Record; +}; + +const platforms = ["linux", "mac", "windows"] as const; + +type Platform = (typeof platforms)[number]; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + const t = useTranslations(); + + const createClientFormSchema = z.object({ + name: z + .string() + .min(2, { message: t("nameMin", { len: 2 }) }) + .max(30, { message: t("nameMax", { len: 30 }) }), + method: z.enum(["olm"]), + siteIds: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .refine((val) => val.length > 0, { + message: t("siteRequired") + }), + subnet: z.string().min(1, { + message: t("subnetRequired") + }) + }); + + type CreateClientFormValues = z.infer; + + const [tunnelTypes, setTunnelTypes] = useState< + ReadonlyArray + >([ + { + id: "olm", + title: t("olmTunnel"), + description: t("olmTunnelDescription"), + disabled: true + } + ]); + + const [loadingPage, setLoadingPage] = useState(true); + const [sites, setSites] = useState([]); + const [activeSitesTagIndex, setActiveSitesTagIndex] = useState< + number | null + >(null); + + const [platform, setPlatform] = useState("linux"); + const [architecture, setArchitecture] = useState("amd64"); + const [commands, setCommands] = useState(null); + + const [olmId, setOlmId] = useState(""); + const [olmSecret, setOlmSecret] = useState(""); + const [olmCommand, setOlmCommand] = useState(""); + + const [createLoading, setCreateLoading] = useState(false); + + const [clientDefaults, setClientDefaults] = + useState(null); + + const hydrateCommands = ( + id: string, + secret: string, + endpoint: string, + version: string + ) => { + const commands = { + mac: { + "Apple Silicon (arm64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + "Intel x64 (amd64)": [ + `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + linux: { + amd64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + arm32v6: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ], + riscv64: [ + `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`, + `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + }, + windows: { + x64: [ + `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_amd64.exe"`, + `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] + } + }; + setCommands(commands); + }; + + const getArchitectures = () => { + switch (platform) { + case "linux": + return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"]; + case "mac": + return ["Apple Silicon (arm64)", "Intel x64 (amd64)"]; + case "windows": + return ["x64"]; + default: + return ["x64"]; + } + }; + + const getPlatformName = (platformName: string) => { + switch (platformName) { + case "windows": + return "Windows"; + case "mac": + return "macOS"; + case "docker": + return "Docker"; + default: + return "Linux"; + } + }; + + const getCommand = () => { + const placeholder = [t("unknownCommand")]; + if (!commands) { + return placeholder; + } + let platformCommands = commands[platform as keyof Commands]; + + if (!platformCommands) { + // get first key + const firstPlatform = Object.keys(commands)[0] as Platform; + platformCommands = commands[firstPlatform as keyof Commands]; + + setPlatform(firstPlatform); + } + + let architectureCommands = platformCommands[architecture]; + if (!architectureCommands) { + // get first key + const firstArchitecture = Object.keys(platformCommands)[0]; + architectureCommands = platformCommands[firstArchitecture]; + + setArchitecture(firstArchitecture); + } + + return architectureCommands || placeholder; + }; + + const getPlatformIcon = (platformName: string) => { + switch (platformName) { + case "windows": + return ; + case "mac": + return ; + case "docker": + return ; + case "podman": + return ; + case "freebsd": + return ; + default: + return ; + } + }; + + const form = useForm({ + resolver: zodResolver(createClientFormSchema), + defaultValues: { + name: "", + method: "olm", + siteIds: [], + subnet: "" + } + }); + + async function onSubmit(data: CreateClientFormValues) { + setCreateLoading(true); + + if (!clientDefaults) { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: t("clientDefaultsNotFound") + }); + setCreateLoading(false); + return; + } + + let payload: CreateClientBody = { + name: data.name, + type: data.method as "olm", + siteIds: data.siteIds.map((site) => parseInt(site.id)), + olmId: clientDefaults.olmId, + secret: clientDefaults.olmSecret, + subnet: data.subnet + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/client`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("errorCreatingClient"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + router.push(`/${orgId}/settings/clients/${data.clientId}`); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + // Fetch available sites + + const res = await api.get>( + `/org/${orgId}/sites/` + ); + const sites = res.data.data.sites.filter( + (s) => s.type === "newt" && s.subnet + ); + setSites( + sites.map((site) => ({ + id: site.siteId.toString(), + text: site.name + })) + ); + + let olmVersion = "latest"; + + try { + const response = await fetch( + `https://api.github.com/repos/fosrl/olm/releases/latest` + ); + if (!response.ok) { + throw new Error( + t("olmErrorFetchReleases", { + err: response.statusText + }) + ); + } + const data = await response.json(); + const latestVersion = data.tag_name; + olmVersion = latestVersion; + } catch (error) { + console.error( + t("olmErrorFetchLatest", { + err: + error instanceof Error + ? error.message + : String(error) + }) + ); + } + + await api + .get(`/org/${orgId}/pick-client-defaults`) + .catch((e) => { + form.setValue("method", "olm"); + }) + .then((res) => { + if (res && res.status === 200) { + const data = res.data.data; + + setClientDefaults(data); + + const olmId = data.olmId; + const olmSecret = data.olmSecret; + const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`; + + setOlmId(olmId); + setOlmSecret(olmSecret); + setOlmCommand(olmCommand); + + hydrateCommands( + olmId, + olmSecret, + env.app.dashboardUrl, + olmVersion + ); + + if (data.subnet) { + form.setValue("subnet", data.subnet); + } + + setTunnelTypes((prev: any) => { + return prev.map((item: any) => { + return { ...item, disabled: false }; + }); + }); + } + }); + + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + + + + {t("clientInformation")} + + + + + + + ( + + + {t("name")} + + + + + + + {t("clientNameDescription")} + + + )} + /> + + ( + + + {t("address")} + + + + + + + {t("addressDescription")} + + + )} + /> + + ( + + + {t("sites")} + + { + form.setValue( + "siteIds", + olmags as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + sites + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + {t("sitesDescription")} + + + + )} + /> + + + + + + + {form.watch("method") === "olm" && ( + <> + + + + {t("clientOlmCredentials")} + + + {t("clientOlmCredentialsDescription")} + + + + + + + {t("olmEndpoint")} + + + + + + + + {t("olmId")} + + + + + + + + {t("olmSecretKey")} + + + + + + + + + + + {t("clientCredentialsSave")} + + + {t( + "clientCredentialsSaveDescription" + )} + + + + + + + + {t("clientInstallOlm")} + + + {t("clientInstallOlmDescription")} + + + +
+

+ {t("operatingSystem")} +

+
+ {platforms.map((os) => ( + + ))} +
+
+ +
+

+ {["docker", "podman"].includes( + platform + ) + ? t("method") + : t("architecture")} +

+
+ {getArchitectures().map( + (arch) => ( + + ) + )} +
+
+

+ {t("commands")} +

+
+ +
+
+
+
+
+ + )} +
+ +
+ + +
+
+ )} + + ); +} \ No newline at end of file From 1466788f77bdbff2468cfa4f81404d7f407a74d7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Jul 2025 21:42:44 -0700 Subject: [PATCH 16/35] Clients ui done --- messages/en-US.json | 13 +- .../[orgId]/settings/clients/ClientsTable.tsx | 10 - .../settings/clients/CreateClientsForm.tsx | 349 ------------------ .../settings/clients/CreateClientsModal.tsx | 80 ---- .../clients/[clientId]/ClientInfoCard.tsx | 12 +- .../clients/[clientId]/general/page.tsx | 30 +- .../[orgId]/settings/clients/create/page.tsx | 6 +- .../[orgId]/settings/sites/create/page.tsx | 5 - 8 files changed, 31 insertions(+), 474 deletions(-) delete mode 100644 src/app/[orgId]/settings/clients/CreateClientsForm.tsx delete mode 100644 src/app/[orgId]/settings/clients/CreateClientsModal.tsx diff --git a/messages/en-US.json b/messages/en-US.json index df2d9799..a9051087 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -59,7 +59,6 @@ "siteErrorCreate": "Error creating site", "siteErrorCreateKeyPair": "Key pair or site defaults not found", "siteErrorCreateDefaults": "Site defaults not found", - "siteNameDescription": "This is the display name for the site.", "method": "Method", "siteMethodDescription": "This is how you will expose connections.", "siteLearnNewt": "Learn how to install Newt on your system", @@ -1291,7 +1290,6 @@ "seeAllClients": "See All Clients", "clientInformation": "Client Information", "clientNamePlaceholder": "Client name", - "clientNameDescription": "A friendly name for this client", "address": "Address", "subnetPlaceholder": "Subnet", "addressDescription": "The address that this client will use for connectivity", @@ -1305,5 +1303,14 @@ "olmId": "Olm ID", "olmSecretKey": "Olm Secret Key", "clientCredentialsSave": "Save Your Credentials", - "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place." + "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", + "generalSettingsDescription": "Configure the general settings for this client", + "clientUpdated": "Client updated", + "clientUpdatedDescription": "The client has been updated.", + "clientUpdateFailed": "Failed to update client", + "clientUpdateError": "An error occurred while updating the client.", + "sitesFetchFailed": "Failed to fetch sites", + "sitesFetchError": "An error occurred while fetching sites.", + "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", + "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release." } \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/ClientsTable.tsx b/src/app/[orgId]/settings/clients/ClientsTable.tsx index 90f04ca8..89766dfc 100644 --- a/src/app/[orgId]/settings/clients/ClientsTable.tsx +++ b/src/app/[orgId]/settings/clients/ClientsTable.tsx @@ -25,7 +25,6 @@ import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import CreateClientFormModal from "./CreateClientsModal"; export type ClientRow = { id: number; @@ -250,15 +249,6 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { return ( <> - { - setRows([val, ...rows]); - }} - orgId={orgId} - /> - {selectedClient && ( val.length > 0, { - message: "At least one site is required." - }), - subnet: z.string().min(1, { - message: "Subnet is required." - }) -}); - -type CreateClientFormValues = z.infer; - -const defaultValues: Partial = { - name: "", - siteIds: [], - subnet: "" -}; - -type CreateClientFormProps = { - onCreate?: (client: ClientRow) => void; - setLoading?: (loading: boolean) => void; - setChecked?: (checked: boolean) => void; - orgId: string; -}; - -export default function CreateClientForm({ - onCreate, - setLoading, - setChecked, - orgId -}: CreateClientFormProps) { - const api = createApiClient(useEnvContext()); - const { env } = useEnvContext(); - - const [sites, setSites] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isChecked, setIsChecked] = useState(false); - const [clientDefaults, setClientDefaults] = - useState(null); - const [olmCommand, setOlmCommand] = useState(null); - const [selectedSites, setSelectedSites] = useState< - Array<{ id: number; name: string }> - >([]); - const [activeSitesTagIndex, setActiveSitesTagIndex] = useState< - number | null - >(null); - - const handleCheckboxChange = (checked: boolean) => { - setIsChecked(checked); - if (setChecked) { - setChecked(checked); - } - }; - - const form = useForm({ - resolver: zodResolver(createClientFormSchema), - defaultValues - }); - - useEffect(() => { - if (!open) return; - - // reset all values - setLoading?.(false); - setIsLoading(false); - form.reset(); - setChecked?.(false); - setClientDefaults(null); - setSelectedSites([]); - - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - const sites = res.data.data.sites.filter( - (s) => s.type === "newt" && s.subnet - ); - setSites( - sites.map((site) => ({ - id: site.siteId.toString(), - text: site.name - })) - ); - }; - - const fetchDefaults = async () => { - api.get(`/org/${orgId}/pick-client-defaults`) - .catch((e) => { - toast({ - variant: "destructive", - title: `Error fetching client defaults`, - description: formatAxiosError(e) - }); - }) - .then((res) => { - if (res && res.status === 200) { - const data = res.data.data; - setClientDefaults(data); - const olmConfig = `olm --id ${data?.olmId} --secret ${data?.olmSecret} --endpoint ${env.app.dashboardUrl}`; - setOlmCommand(olmConfig); - - // Set the subnet value from client defaults - if (data?.subnet) { - form.setValue("subnet", data.subnet); - } - } - }); - }; - fetchSites(); - fetchDefaults(); - }, [open]); - - async function onSubmit(data: CreateClientFormValues) { - setLoading?.(true); - setIsLoading(true); - - if (!clientDefaults) { - toast({ - variant: "destructive", - title: "Error creating client", - description: "Client defaults not found" - }); - setLoading?.(false); - setIsLoading(false); - return; - } - - const payload = { - name: data.name, - siteIds: data.siteIds.map((site) => parseInt(site.id)), - olmId: clientDefaults.olmId, - secret: clientDefaults.olmSecret, - subnet: data.subnet, - type: "olm" - } as CreateClientBody; - - const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/client`, payload) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error creating client", - description: formatAxiosError(e) - }); - }); - - if (res && res.status === 201) { - const data = res.data.data; - - onCreate?.({ - name: data.name, - id: data.clientId, - subnet: data.subnet, - mbIn: "0 MB", - mbOut: "0 MB", - orgId: orgId as string, - online: false - }); - } - - setLoading?.(false); - setIsLoading(false); - } - - return ( -
-
- - ( - - Name - - - - - - )} - /> - - ( - - Address - - - - - The address that this client will use for - connectivity. - - - - )} - /> - - ( - - Sites - { - form.setValue( - "siteIds", - newTags as [Tag, ...Tag[]] - ); - }} - enableAutocomplete={true} - autocompleteOptions={sites} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} - /> - - The client will have connectivity to the - selected sites. The sites must be configured - to accept client connections. - - - - )} - /> - - {olmCommand && ( -
-
-
- -
-
- - You will only be able to see the configuration - once. - -
- )} - -
- - -
- - -
- ); -} diff --git a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx deleted file mode 100644 index a8921cb1..00000000 --- a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { Button } from "@app/components/ui/button"; -import { useState } from "react"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import CreateClientForm from "./CreateClientsForm"; -import { ClientRow } from "./ClientsTable"; - -type CreateClientFormProps = { - open: boolean; - setOpen: (open: boolean) => void; - onCreate?: (client: ClientRow) => void; - orgId: string; -}; - -export default function CreateClientFormModal({ - open, - setOpen, - onCreate, - orgId -}: CreateClientFormProps) { - const [loading, setLoading] = useState(false); - const [isChecked, setIsChecked] = useState(false); - - return ( - <> - { - setOpen(val); - setLoading(false); - }} - > - - - Create Client - - Create a new client to connect to your sites - - - -
- setLoading(val)} - setChecked={(val) => setIsChecked(val)} - onCreate={onCreate} - orgId={orgId} - /> -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx index 7117b4d5..ec8ecacf 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx @@ -9,38 +9,40 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; type ClientInfoCardProps = {}; export default function SiteInfoCard({}: ClientInfoCardProps) { const { client, updateClient } = useClientContext(); + const t = useTranslations(); return ( - Client Information + {t("clientInformation")} <> - Status + {t("status")} {client.online ? (
- Online + {t("online")}
) : (
- Offline + {t("offline")}
)}
- Address + {t("address")} {client.subnet.split("/")[0]} diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx index e02e3aaa..27d708a4 100644 --- a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx @@ -34,6 +34,7 @@ import { useEffect, useState } from "react"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { AxiosResponse } from "axios"; import { ListSitesResponse } from "@server/routers/site"; +import { useTranslations } from "next-intl"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -48,6 +49,7 @@ const GeneralFormSchema = z.object({ type GeneralFormValues = z.infer; export default function GeneralPage() { + const t = useTranslations(); const { client, updateClient } = useClientContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); @@ -119,18 +121,18 @@ export default function GeneralPage() { updateClient({ name: data.name }); toast({ - title: "Client updated", - description: "The client has been updated." + title: t("clientUpdated"), + description: t("clientUpdatedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", - title: "Failed to update client", + title: t("clientUpdateFailed"), description: formatAxiosError( e, - "An error occurred while updating the client." + t("clientUpdateError") ) }); } finally { @@ -143,10 +145,10 @@ export default function GeneralPage() { - General Settings + {t("generalSettings")} - Configure the general settings for this client + {t("generalSettingsDescription")} @@ -163,15 +165,11 @@ export default function GeneralPage() { name="name" render={({ field }) => ( - Name + {t("name")} - - This is the display name of the - client. - )} /> @@ -181,12 +179,12 @@ export default function GeneralPage() { name="siteIds" render={(field) => ( - Sites + {t("sites")} { @@ -202,9 +200,7 @@ export default function GeneralPage() { sortTags={true} /> - The client will have connectivity to the - selected sites. The sites must be configured - to accept client connections. + {t("sitesDescription")} @@ -222,7 +218,7 @@ export default function GeneralPage() { loading={loading} disabled={loading} > - Save Settings + {t("saveSettings")} diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx index 850504f5..88d2bef2 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -100,7 +100,7 @@ export default function Page() { .refine((val) => val.length > 0, { message: t("siteRequired") }), - subnet: z.string().min(1, { + subnet: z.string().ip().min(1, { message: t("subnetRequired") }) }); @@ -442,14 +442,10 @@ export default function Page() { - - {t("clientNameDescription")} - )} /> diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 454f609e..9ea254b1 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -587,11 +587,6 @@ WantedBy=default.target` /> - - {t( - "siteNameDescription" - )} - )} /> From 15adfcca8cf745d22bc4785144e99b64b16cc3ec Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Jul 2025 22:01:22 -0700 Subject: [PATCH 17/35] Add remote subnets to ui --- messages/en-US.json | 5 +- server/db/pg/schema.ts | 13 ++-- server/db/sqlite/schema.ts | 3 +- server/routers/site/updateSite.ts | 19 ++++++ .../settings/sites/[niceId]/general/page.tsx | 66 +++++++++++++++++-- 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a9051087..8e78c3d2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1312,5 +1312,8 @@ "sitesFetchFailed": "Failed to fetch sites", "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", - "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release." + "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." } \ No newline at end of file diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 77be5f1b..d774a985 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -59,7 +59,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + remoteSubnets: text("remoteSubnets") // comma-separated list of subnets that this site can access }); export const resources = pgTable("resources", { @@ -542,7 +543,7 @@ export const olmSessions = pgTable("clientSession", { olmId: varchar("olmId") .notNull() .references(() => olms.olmId, { onDelete: "cascade" }), - expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), + expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const userClients = pgTable("userClients", { @@ -565,9 +566,11 @@ export const roleClients = pgTable("roleClients", { export const securityKeys = pgTable("webauthnCredentials", { credentialId: varchar("credentialId").primaryKey(), - userId: varchar("userId").notNull().references(() => users.userId, { - onDelete: "cascade" - }), + userId: varchar("userId") + .notNull() + .references(() => users.userId, { + onDelete: "cascade" + }), publicKey: varchar("publicKey").notNull(), signCount: integer("signCount").notNull(), transports: varchar("transports"), diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 2c44b593..d372856d 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -65,7 +65,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + remoteSubnets: text("remoteSubnets"), // comma-separated list of subnets that this site can access }); export const resources = sqliteTable("resources", { diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index a5a5f7c0..e3724f36 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; const updateSiteParamsSchema = z .object({ @@ -20,6 +21,9 @@ const updateSiteBodySchema = z .object({ name: z.string().min(1).max(255).optional(), dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z + .string() + .optional() // subdomain: z // .string() // .min(1) @@ -85,6 +89,21 @@ export async function updateSite( const { siteId } = parsedParams.data; const updateData = parsedBody.data; + // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs + if (updateData.remoteSubnets) { + const subnets = updateData.remoteSubnets.split(",").map((s) => s.trim()); + for (const subnet of subnets) { + if (!isValidCIDR(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Invalid CIDR format: ${subnet}` + ) + ); + } + } + } + const updatedSite = await db .update(sites) .set(updateData) diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index ba1f877c..1581d961 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -33,10 +33,17 @@ import { useState } from "react"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + remoteSubnets: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ).optional() }); type GeneralFormValues = z.infer; @@ -44,9 +51,11 @@ type GeneralFormValues = z.infer; export default function GeneralPage() { const { site, updateSite } = useSiteContext(); + const { env } = useEnvContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); + const [activeCidrTagIndex, setActiveCidrTagIndex] = useState(null); const router = useRouter(); const t = useTranslations(); @@ -55,7 +64,13 @@ export default function GeneralPage() { resolver: zodResolver(GeneralFormSchema), defaultValues: { name: site?.name, - dockerSocketEnabled: site?.dockerSocketEnabled ?? false + dockerSocketEnabled: site?.dockerSocketEnabled ?? false, + remoteSubnets: site?.remoteSubnets + ? site.remoteSubnets.split(',').map((subnet, index) => ({ + id: subnet.trim(), + text: subnet.trim() + })) + : [] }, mode: "onChange" }); @@ -66,7 +81,8 @@ export default function GeneralPage() { await api .post(`/site/${site?.siteId}`, { name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }) .catch((e) => { toast({ @@ -81,7 +97,8 @@ export default function GeneralPage() { updateSite({ name: data.name, - dockerSocketEnabled: data.dockerSocketEnabled + dockerSocketEnabled: data.dockerSocketEnabled, + remoteSubnets: data.remoteSubnets?.map(subnet => subnet.text).join(',') || '' }); toast({ @@ -124,12 +141,47 @@ export default function GeneralPage() { - - {t("siteNameDescription")} - )} /> + + ( + + {t("remoteSubnets")} + + { + form.setValue( + "remoteSubnets", + newSubnets as Tag[] + ); + }} + validateTag={(tag) => { + // Basic CIDR validation regex + const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/; + return cidrRegex.test(tag); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("remoteSubnetsDescription")} + + + + )} + /> + {site && site.type === "newt" && ( Date: Sun, 27 Jul 2025 10:21:27 -0700 Subject: [PATCH 18/35] Basic clients working --- messages/en-US.json | 7 +- server/db/pg/schema.ts | 3 +- server/db/sqlite/schema.ts | 3 +- server/routers/client/updateClient.ts | 3 +- server/routers/newt/handleGetConfigMessage.ts | 100 ++++++++++++++- server/routers/newt/targets.ts | 42 +++++-- .../routers/olm/handleOlmRegisterMessage.ts | 15 +-- server/routers/olm/peers.ts | 8 +- server/routers/resource/createResource.ts | 10 +- server/routers/resource/deleteResource.ts | 3 +- server/routers/resource/transferResource.ts | 6 +- server/routers/resource/updateResource.ts | 3 +- server/routers/target/createTarget.ts | 2 +- server/routers/target/deleteTarget.ts | 2 +- server/routers/target/updateTarget.ts | 2 +- server/routers/traefik/getTraefikConfig.ts | 7 +- server/setup/scriptsSqlite/1.8.0.ts | 29 +++++ src/app/[orgId]/settings/clients/page.tsx | 2 +- .../[resourceId]/ResourceInfoBox.tsx | 37 ++++-- .../resources/[resourceId]/general/page.tsx | 118 +++++++++++++----- .../settings/resources/create/page.tsx | 72 +++++++++-- 21 files changed, 387 insertions(+), 87 deletions(-) create mode 100644 server/setup/scriptsSqlite/1.8.0.ts diff --git a/messages/en-US.json b/messages/en-US.json index 8e78c3d2..4ff1b866 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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" } \ No newline at end of file diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index d774a985..5709c9f8 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -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", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index d372856d..974faa67 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -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", { diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 87bb3c47..73c67d53 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -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 }); } diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index ce887b98..2d6ed98b 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -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", diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index d3c541a6..642fc2df 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -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 + } + }); } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index f504ecd7..8a73daff 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -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 }); } diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 48a915aa..c47c84a8 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -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 } }); diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 8f16b198..dfbb7617 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -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(); diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index bb9a6f32..99adc5f7 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -103,7 +103,8 @@ export async function deleteResource( removeTargets( newt.newtId, targetsToBeRemoved, - deletedResource.protocol + deletedResource.protocol, + deletedResource.proxyPort ); } } diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index e0fce278..a99405df 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -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 ); } } diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a20a7024..e99c6e8b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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, { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 52bd0417..ffea1571 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -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); } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 17a9c5ee..6eadeccd 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -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); } } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 0138520b..0b7c4692 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -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, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index c876de22..882a296a 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -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; diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts new file mode 100644 index 00000000..efb4f68a --- /dev/null +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -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`); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index b798bf93..83cc11e3 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -48,7 +48,7 @@ export default async function ClientsPage(props: ClientsPageProps) { return ( <> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 717e4d49..cc4408b2 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -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) { - {t('resourceInfo')} + {t("resourceInfo")} @@ -42,7 +43,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { <> - {t('authentication')} + {t("authentication")} {authInfo.password || @@ -51,12 +52,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { authInfo.whitelist ? (
- {t('protected')} + {t("protected")}
) : (
- {t('notProtected')} + {t("notProtected")}
)}
@@ -71,7 +72,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
- {t('site')} + {t("site")} {resource.siteName} @@ -98,7 +99,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { ) : ( <> - {t('protocol')} + + {t("protocol")} + {resource.protocol.toUpperCase()} @@ -106,7 +109,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - {t('port')} + {t("port")} + {build == "oss" && ( + + + {t("externalProxyEnabled")} + + + + {resource.enableProxy + ? t("enabled") + : t("disabled")} + + + + )} )} - {t('visibility')} + {t("visibility")} - {resource.enabled ? t('enabled') : t('disabled')} + {resource.enabled + ? t("enabled") + : t("disabled")} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index efda61c3..266911a6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -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({ - 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) => { - // 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"] - }); + 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(), + 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; @@ -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 }) => ( - {t("resourcePortNumber")} + {t( + "resourcePortNumber" + )} + 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() { - {t("resourcePortNumberDescription")} + {t( + "resourcePortNumberDescription" + )} )} /> + + {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} )} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 22e9d90c..a916a700 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -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; @@ -144,7 +147,8 @@ export default function Page() { resolver: zodResolver(tcpUdpResourceFormSchema), defaultValues: { protocol: "tcp", - proxyPort: undefined + proxyPort: undefined, + enableProxy: false } }); @@ -163,16 +167,17 @@ export default function Page() { if (isHttp) { const httpData = httpForm.getValues(); - Object.assign(payload, { - subdomain: httpData.subdomain, - domainId: httpData.domainId, - protocol: "tcp", - }); + Object.assign(payload, { + subdomain: httpData.subdomain, + domainId: httpData.domainId, + 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 { - setShowSnippets(true); - router.refresh(); + 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() { )} /> + + {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} From e105a523e41d071ddabcb21a6923ef74ac962bb4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 27 Jul 2025 14:11:36 -0700 Subject: [PATCH 19/35] Add log and fix default --- install/config/config.yml | 2 +- server/routers/gerbil/updateHolePunch.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/install/config/config.yml b/install/config/config.yml index 5f81c141..fc41cfe0 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -24,7 +24,7 @@ gerbil: orgs: block_size: 24 - subnet_group: 100.89.138.0/20 + subnet_group: 100.90.128.0/24 {{if .EnableEmail}} email: diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index e99225fe..4910738e 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -125,6 +125,8 @@ export async function updateHolePunch( } } else if (newtId) { + logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`); + const { session, newt: newtSession } = await validateNewtSessionToken(token); From 2ca8febff7b07e4f192381ab14ef20800e687f18 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 27 Jul 2025 14:12:01 -0700 Subject: [PATCH 20/35] We dont need this config --- install/config/config.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/install/config/config.yml b/install/config/config.yml index fc41cfe0..00d7c897 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -22,10 +22,6 @@ gerbil: start_port: 51820 base_endpoint: "{{.DashboardDomain}}" -orgs: - block_size: 24 - subnet_group: 100.90.128.0/24 - {{if .EnableEmail}} email: smtp_host: "{{.EmailSMTPHost}}" From 67bae760489c58cf4b55c0eff061c5a1488ebb2a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 28 Jul 2025 12:21:15 -0700 Subject: [PATCH 21/35] minor visual tweaks to member landing --- src/app/[orgId]/MemberResourcesPortal.tsx | 538 +++++++++++----------- src/app/[orgId]/page.tsx | 6 +- src/components/ui/info-popup.tsx | 12 +- 3 files changed, 275 insertions(+), 281 deletions(-) diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx index 142d5516..ad412b1e 100644 --- a/src/app/[orgId]/MemberResourcesPortal.tsx +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -4,21 +4,42 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { ExternalLink, Globe, ShieldCheck, Search, RefreshCw, AlertCircle, Plus, Shield, ShieldOff, ChevronLeft, ChevronRight, Building2, Key, KeyRound, Fingerprint, AtSign, Copy, InfoIcon } from "lucide-react"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { + ExternalLink, + Globe, + Search, + RefreshCw, + AlertCircle, + ChevronLeft, + ChevronRight, + Key, + KeyRound, + Fingerprint, + AtSign, + Copy, + InfoIcon, + Combine +} from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { GetUserResourcesResponse } from "@server/routers/resource/getUserResources"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { useToast } from "@app/hooks/useToast"; +import { InfoPopup } from "@/components/ui/info-popup"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@/components/ui/tooltip"; // Update Resource type to include site information type Resource = { @@ -42,26 +63,34 @@ type MemberResourcesPortalProps = { }; // Favicon component with fallback -const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean }) => { +const ResourceFavicon = ({ + domain, + enabled +}: { + domain: string; + enabled: boolean; +}) => { const [faviconError, setFaviconError] = useState(false); const [faviconLoaded, setFaviconLoaded] = useState(false); - + // Extract domain for favicon URL - const cleanDomain = domain.replace(/^https?:\/\//, '').split('/')[0]; + const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; - + const handleFaviconLoad = () => { setFaviconLoaded(true); setFaviconError(false); }; - + const handleFaviconError = () => { setFaviconError(true); setFaviconLoaded(false); }; if (faviconError || !enabled) { - return ; + return ( + + ); } return ( @@ -72,7 +101,7 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean {`${cleanDomain} @@ -80,198 +109,107 @@ const ResourceFavicon = ({ domain, enabled }: { domain: string; enabled: boolean ); }; -// Enhanced status badge component -const StatusBadge = ({ enabled, protected: isProtected, resource }: { enabled: boolean; protected: boolean; resource: Resource }) => { - if (!enabled) { - return ( - - - -
-
-
-
- -

Resource Disabled

-
-
-
- ); - } - - if (isProtected) { - return ( - - - -
- -
-
- -

Protected Resource

-
-

Authentication Methods:

-
- {resource.sso && ( -
-
- -
- Single Sign-On (SSO) -
- )} - {resource.password && ( -
-
- -
- Password Protected -
- )} - {resource.pincode && ( -
-
- -
- PIN Code -
- )} - {resource.whitelist && ( -
-
- -
- Email Whitelist -
- )} -
-
-
-
-
- ); - } - - return ( -
- -
- ); -}; - // Resource Info component const ResourceInfo = ({ resource }: { resource: Resource }) => { - const hasAuthMethods = resource.sso || resource.password || resource.pincode || resource.whitelist; - - return ( - - - -
- + const hasAuthMethods = + resource.sso || + resource.password || + resource.pincode || + resource.whitelist; + + const infoContent = ( +
+ {/* Site Information */} + {resource.siteName && ( +
+
Site
+
+ + {resource.siteName}
- - - {/* Site Information */} - {resource.siteName && ( -
-
Site
-
- - {resource.siteName} -
-
- )} +
+ )} - {/* Authentication Methods */} - {hasAuthMethods && ( -
-
Authentication Methods
-
- {resource.sso && ( -
-
- -
- Single Sign-On (SSO) -
- )} - {resource.password && ( -
-
- -
- Password Protected -
- )} - {resource.pincode && ( -
-
- -
- PIN Code -
- )} - {resource.whitelist && ( -
-
- -
- Email Whitelist -
- )} -
-
- )} - - {/* Resource Status - if disabled */} - {!resource.enabled && ( -
-
- - Resource Disabled -
-
- )} - - - - ); -}; - -// Site badge component -const SiteBadge = ({ resource }: { resource: Resource }) => { - if (!resource.siteName) { - return null; - } - - return ( - - - -
- + {/* Authentication Methods */} + {hasAuthMethods && ( +
+
+ Authentication Methods
- - -

{resource.siteName}

-
- - +
+ {resource.sso && ( +
+
+ +
+ + Single Sign-On (SSO) + +
+ )} + {resource.password && ( +
+
+ +
+ + Password Protected + +
+ )} + {resource.pincode && ( +
+
+ +
+ PIN Code +
+ )} + {resource.whitelist && ( +
+
+ +
+ Email Whitelist +
+ )} +
+
+ )} + + {/* Resource Status - if disabled */} + {!resource.enabled && ( +
+
+ + + Resource Disabled + +
+
+ )} +
); + + return {infoContent}; }; // Pagination component -const PaginationControls = ({ - currentPage, - totalPages, +const PaginationControls = ({ + currentPage, + totalPages, onPageChange, totalItems, - itemsPerPage -}: { - currentPage: number; - totalPages: number; + itemsPerPage +}: { + currentPage: number; + totalPages: number; onPageChange: (page: number) => void; totalItems: number; itemsPerPage: number; @@ -286,7 +224,7 @@ const PaginationControls = ({
Showing {startItem}-{endItem} of {totalItems} resources
- +
- +
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { - // Show first page, last page, current page, and 2 pages around current - const showPage = - page === 1 || - page === totalPages || - Math.abs(page - currentPage) <= 1; - - const showEllipsis = - (page === 2 && currentPage > 4) || - (page === totalPages - 1 && currentPage < totalPages - 3); + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (page) => { + // Show first page, last page, current page, and 2 pages around current + const showPage = + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1; - if (!showPage && !showEllipsis) return null; + const showEllipsis = + (page === 2 && currentPage > 4) || + (page === totalPages - 1 && + currentPage < totalPages - 3); + + if (!showPage && !showEllipsis) return null; + + if (showEllipsis) { + return ( + + ... + + ); + } - if (showEllipsis) { return ( - - ... - + ); } - - return ( - - ); - })} + )}
- +
- + {/* Sort */}
@@ -595,7 +567,9 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr disabled={refreshing} className="gap-2 shrink-0" > - + Refresh
@@ -603,7 +577,7 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr {/* Resources Content */} {filteredResources.length === 0 ? ( /* Enhanced Empty State */ - +
{searchQuery ? ( @@ -613,17 +587,18 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr )}

- {searchQuery ? "No Resources Found" : "No Resources Available"} + {searchQuery + ? "No Resources Found" + : "No Resources Available"}

- {searchQuery + {searchQuery ? `No resources match "${searchQuery}". Try adjusting your search terms or clearing the search to see all resources.` - : "You don't have access to any resources yet. Contact your administrator to get access to resources you need." - } + : "You don't have access to any resources yet. Contact your administrator to get access to resources you need."}

{searchQuery ? ( - ) : ( - )} @@ -649,12 +626,15 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr {/* Resources Grid */}
{paginatedResources.map((resource) => ( - +
- +
@@ -664,12 +644,14 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr -

{resource.name}

+

+ {resource.name} +

- +
@@ -677,21 +659,29 @@ export default function MemberResourcesPortal({ orgId }: MemberResourcesPortalPr
-
); -} \ No newline at end of file +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 9a1dda94..4740198b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -62,11 +62,7 @@ export default async function OrgPage(props: OrgPageProps) { return ( - {overview && ( -
- -
- )} + {overview && }
); diff --git a/src/components/ui/info-popup.tsx b/src/components/ui/info-popup.tsx index 732c23e9..cff1cce4 100644 --- a/src/components/ui/info-popup.tsx +++ b/src/components/ui/info-popup.tsx @@ -11,11 +11,12 @@ import { Button } from "@/components/ui/button"; interface InfoPopupProps { text?: string; - info: string; + info?: string; trigger?: React.ReactNode; + children?: React.ReactNode; } -export function InfoPopup({ text, info, trigger }: InfoPopupProps) { +export function InfoPopup({ text, info, trigger, children }: InfoPopupProps) { const defaultTrigger = (
From 80aa7502af1abeffdd52d1ec0691b3792713d5d2 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 28 Jul 2025 12:52:44 -0700 Subject: [PATCH 22/35] fix resource domain not required --- messages/en-US.json | 4 +-- .../resources/[resourceId]/general/page.tsx | 1 + .../settings/resources/create/page.tsx | 10 +++--- src/app/navigation.tsx | 2 +- src/components/DomainPicker.tsx | 33 ++++++++++--------- 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4ff1b866..e69e2b46 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1093,7 +1093,7 @@ "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", - "sidebarClients": "Clients (beta)", + "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.", @@ -1319,4 +1319,4 @@ "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" -} \ No newline at end of file +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 266911a6..68cc02cc 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -636,6 +636,7 @@ export default function GeneralForm() { { const selected = { domainId: res.domainId, diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index a916a700..fc90d26c 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -74,7 +74,7 @@ const baseResourceFormSchema = z.object({ }); const httpResourceFormSchema = z.object({ - domainId: z.string().optional(), + domainId: z.string().nonempty(), subdomain: z.string().optional() }); @@ -277,9 +277,9 @@ export default function Page() { if (res?.status === 200) { const domains = res.data.data.domains; setBaseDomains(domains); - if (domains.length) { - httpForm.setValue("domainId", domains[0].domainId); - } + // if (domains.length) { + // httpForm.setValue("domainId", domains[0].domainId); + // } } }; @@ -684,6 +684,8 @@ export default function Page() { ? await httpForm.trigger() : await tcpUdpForm.trigger(); + console.log(httpForm.getValues()); + if (baseValid && settingsValid) { onSubmit(); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 9901ee2f..b26b98ec 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -134,4 +134,4 @@ export const adminNavSections: SidebarNavSection[] = [ : []) ] } -]; \ No newline at end of file +]; diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 1b96ec8e..28dbcdbd 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -49,6 +49,7 @@ type DomainOption = { interface DomainPickerProps { orgId: string; + cols?: number; onDomainChange?: (domainInfo: { domainId: string; domainNamespaceId?: string; @@ -61,6 +62,7 @@ interface DomainPickerProps { export default function DomainPicker({ orgId, + cols, onDomainChange }: DomainPickerProps) { const { env } = useEnvContext(); @@ -309,6 +311,7 @@ export default function DomainPicker({ { // Only allow letters, numbers, hyphens, and periods const validInput = e.target.value.replace( @@ -393,23 +396,25 @@ export default function DomainPicker({ {/* Organization Domains */} {organizationOptions.length > 0 && (
-
- -

- {t("domainPickerOrganizationDomains")} -

-
-
+ {build !== "oss" && ( +
+ +

+ {t("domainPickerOrganizationDomains")} +

+
+ )} +
{organizationOptions.map((option) => (
@@ -456,10 +461,6 @@ export default function DomainPicker({

)}
- {selectedOption?.id === - option.id && ( - - )}
))} @@ -476,14 +477,14 @@ export default function DomainPicker({ {t("domainPickerProvidedDomains")}
-
+
{providedOptions.map((option) => (
Date: Mon, 28 Jul 2025 12:53:00 -0700 Subject: [PATCH 23/35] Dont send enableProxy --- server/routers/resource/createResource.ts | 21 +++++++------------ server/routers/resource/updateResource.ts | 21 ++++++++----------- .../resources/[resourceId]/general/page.tsx | 8 +++++-- 3 files changed, 23 insertions(+), 27 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index dfbb7617..8c80c90c 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -33,14 +33,11 @@ const createResourceParamsSchema = z const createHttpResourceSchema = z .object({ name: z.string().min(1).max(255), - subdomain: z - .string() - .nullable() - .optional(), + subdomain: z.string().nullable().optional(), siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), - domainId: z.string(), + domainId: z.string() }) .strict() .refine( @@ -51,7 +48,7 @@ const createHttpResourceSchema = z return true; }, { message: "Invalid subdomain" } - ) + ); const createRawResourceSchema = z .object({ @@ -89,12 +86,7 @@ registry.registerPath({ body: { content: { "application/json": { - schema: - build == "oss" - ? createHttpResourceSchema.or( - createRawResourceSchema - ) - : createHttpResourceSchema + schema: createHttpResourceSchema.or(createRawResourceSchema) } } } @@ -157,7 +149,10 @@ export async function createResource( { siteId, orgId } ); } else { - if (!config.getRawConfig().flags?.allow_raw_resources && build == "oss") { + if ( + !config.getRawConfig().flags?.allow_raw_resources && + build == "oss" + ) { return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index e99c6e8b..5cf68c2b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -34,9 +34,7 @@ const updateResourceParamsSchema = z const updateHttpResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), - subdomain: subdomainSchema - .nullable() - .optional(), + subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), @@ -94,7 +92,7 @@ const updateRawResourceBodySchema = z proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), enabled: z.boolean().optional(), - enableProxy: z.boolean().optional(), + enableProxy: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -122,12 +120,9 @@ registry.registerPath({ body: { content: { "application/json": { - schema: - build == "oss" - ? updateHttpResourceBodySchema.and( - updateRawResourceBodySchema - ) - : updateHttpResourceBodySchema + schema: updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) } } } @@ -289,7 +284,9 @@ async function updateHttpResource( } else if (domainRes.domains.type == "wildcard") { if (updateData.subdomain !== undefined) { // the subdomain cant have a dot in it - const parsedSubdomain = subdomainSchema.safeParse(updateData.subdomain); + const parsedSubdomain = subdomainSchema.safeParse( + updateData.subdomain + ); if (!parsedSubdomain.success) { return next( createHttpError( @@ -342,7 +339,7 @@ async function updateHttpResource( const updatedResource = await db .update(resources) - .set({...updateData, }) + .set({ ...updateData }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 68cc02cc..b4e14d64 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -221,7 +221,9 @@ export default function GeneralForm() { subdomain: data.subdomain, domainId: data.domainId, proxyPort: data.proxyPort, - enableProxy: data.enableProxy + ...(!resource.http && { + enableProxy: data.enableProxy + }) } ) .catch((e) => { @@ -249,7 +251,9 @@ export default function GeneralForm() { subdomain: data.subdomain, fullDomain: resource.fullDomain, proxyPort: data.proxyPort, - enableProxy: data.enableProxy + ...(!resource.http && { + enableProxy: data.enableProxy + }), }); router.refresh(); From 494329f568ddea3ea08352031add0fa58e83e0dd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 28 Jul 2025 12:55:11 -0700 Subject: [PATCH 24/35] delete resources on delete org --- server/routers/org/deleteOrg.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 41b491a2..2f4ddf9e 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { newts, newtSessions, @@ -127,6 +127,7 @@ export async function deleteOrg( } await trx.delete(orgs).where(eq(orgs.orgId, orgId)); + await trx.delete(resources).where(eq(resources.orgId, orgId)); }); // Send termination messages outside of transaction to prevent blocking From adc0a81592b5827cdfee56af695c9cb3b16c0ed4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 28 Jul 2025 15:34:56 -0700 Subject: [PATCH 25/35] delete org domains and resources on org delete --- server/routers/org/deleteOrg.ts | 58 ++++++++++++++++++++++++++------- src/components/DomainPicker.tsx | 5 ++- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 2f4ddf9e..76e2ad79 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,14 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources } from "@server/db"; -import { - newts, - newtSessions, - orgs, - sites, - userActions -} from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, domains, orgDomains, resources } from "@server/db"; +import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -126,8 +120,45 @@ export async function deleteOrg( } } - await trx.delete(orgs).where(eq(orgs.orgId, orgId)); + const allOrgDomains = await trx + .select() + .from(orgDomains) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(domains.configManaged, false) + ) + ); + + // For each domain, check if it belongs to multiple organizations + const domainIdsToDelete: string[] = []; + for (const orgDomain of allOrgDomains) { + const domainId = orgDomain.domains.domainId; + + // Count how many organizations this domain belongs to + const orgCount = await trx + .select({ count: sql`count(*)` }) + .from(orgDomains) + .where(eq(orgDomains.domainId, domainId)); + + // Only delete the domain if it belongs to exactly 1 organization (the one being deleted) + if (orgCount[0].count === 1) { + domainIdsToDelete.push(domainId); + } + } + + // Delete domains that belong exclusively to this organization + if (domainIdsToDelete.length > 0) { + await trx + .delete(domains) + .where(inArray(domains.domainId, domainIdsToDelete)); + } + + // Delete resources await trx.delete(resources).where(eq(resources.orgId, orgId)); + + await trx.delete(orgs).where(eq(orgs.orgId, orgId)); }); // Send termination messages outside of transaction to prevent blocking @@ -137,8 +168,11 @@ export async function deleteOrg( data: {} }; // Don't await this to prevent blocking the response - sendToClient(newtId, payload).catch(error => { - logger.error("Failed to send termination message to newt:", error); + sendToClient(newtId, payload).catch((error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); }); } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 28dbcdbd..5f4104ea 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -129,9 +129,6 @@ export default function DomainPicker({ if (!userInput.trim()) return options; - // Check if input is more than one level deep (contains multiple dots) - const isMultiLevel = (userInput.match(/\./g) || []).length > 1; - // Add organization domain options organizationDomains.forEach((orgDomain) => { if (orgDomain.type === "cname") { @@ -319,6 +316,8 @@ export default function DomainPicker({ "" ); setUserInput(validInput); + // Clear selection when input changes + setSelectedOption(null); }} />

From 80656f48e065ed7bc949f3566bf11834495f9ebf Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 17:18:51 -0700 Subject: [PATCH 26/35] Sqlite migration done --- server/lib/readConfigFile.ts | 2 +- server/setup/scriptsPg/1.8.0.ts | 25 +++++++++++++++++++++++++ server/setup/scriptsSqlite/1.8.0.ts | 3 ++- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 server/setup/scriptsPg/1.8.0.ts diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 63951876..42fcefd3 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -229,7 +229,7 @@ export const configSchema = z disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), - enable_clients: z.boolean().optional() + enable_clients: z.boolean().optional().default(true), }) .optional(), dns: z diff --git a/server/setup/scriptsPg/1.8.0.ts b/server/setup/scriptsPg/1.8.0.ts new file mode 100644 index 00000000..43b2a996 --- /dev/null +++ b/server/setup/scriptsPg/1.8.0.ts @@ -0,0 +1,25 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.7.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql` + BEGIN; + + + COMMIT; + `); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts index efb4f68a..bcee3e8d 100644 --- a/server/setup/scriptsSqlite/1.8.0.ts +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -13,9 +13,10 @@ export default async function migration() { try { db.transaction(() => { db.exec(` + ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT true; + ALTER TABLE 'sites' ADD 'remoteSubnets' text; ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text; ALTER TABLE 'user' ADD 'termsVersion' text; - ALTER TABLE 'sites' ADD 'remoteSubnets' text; `); })(); From 4d7e25f97bb4df93acaf70f8b1390befecc52d68 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 17:22:53 -0700 Subject: [PATCH 27/35] Complete migrations --- server/db/pg/schema.ts | 2 +- server/lib/consts.ts | 2 +- server/setup/migrationsPg.ts | 4 +++- server/setup/migrationsSqlite.ts | 2 ++ server/setup/scriptsPg/1.8.0.ts | 9 ++++++++- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 5709c9f8..b9228286 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -95,7 +95,7 @@ export const resources = pgTable("resources", { stickySession: boolean("stickySession").notNull().default(false), tlsServerName: varchar("tlsServerName"), setHostHeader: varchar("setHostHeader"), - enableProxy: boolean("enableProxy").notNull().default(true), + enableProxy: boolean("enableProxy").default(true), }); export const targets = pgTable("targets", { diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 70d4404a..cfe45620 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.7.3"; +export const APP_VERSION = "1.8.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 6996999c..07ece65b 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -7,6 +7,7 @@ import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; +import m3 from "./scriptsPg/1.8.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -14,7 +15,8 @@ import m2 from "./scriptsPg/1.7.0"; // Define the migration list with versions and their corresponding functions const migrations = [ { version: "1.6.0", run: m1 }, - { version: "1.7.0", run: m2 } + { version: "1.7.0", run: m2 }, + { version: "1.8.0", run: m3 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 9fd5a470..15dd28d2 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -24,6 +24,7 @@ import m19 from "./scriptsSqlite/1.3.0"; import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; import m22 from "./scriptsSqlite/1.7.0"; +import m23 from "./scriptsSqlite/1.8.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -47,6 +48,7 @@ const migrations = [ { version: "1.5.0", run: m20 }, { version: "1.6.0", run: m21 }, { version: "1.7.0", run: m22 }, + { version: "1.8.0", run: m23 }, // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.8.0.ts b/server/setup/scriptsPg/1.8.0.ts index 43b2a996..c1891c12 100644 --- a/server/setup/scriptsPg/1.8.0.ts +++ b/server/setup/scriptsPg/1.8.0.ts @@ -9,7 +9,14 @@ export default async function migration() { try { await db.execute(sql` BEGIN; - + + ALTER TABLE "clients" ALTER COLUMN "bytesIn" SET DATA TYPE real; + ALTER TABLE "clients" ALTER COLUMN "bytesOut" SET DATA TYPE real; + ALTER TABLE "clientSession" ALTER COLUMN "expiresAt" SET DATA TYPE bigint; + ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true; + ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text; + ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar; + ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; COMMIT; `); From d732c1a8459e62fa641350a38a27124c004c9c7d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 17:32:15 -0700 Subject: [PATCH 28/35] Clean up migrations --- server/setup/scriptsPg/1.8.0.ts | 4 ++-- server/setup/scriptsSqlite/1.8.0.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/setup/scriptsPg/1.8.0.ts b/server/setup/scriptsPg/1.8.0.ts index c1891c12..7c0b181b 100644 --- a/server/setup/scriptsPg/1.8.0.ts +++ b/server/setup/scriptsPg/1.8.0.ts @@ -1,7 +1,7 @@ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; -const version = "1.7.0"; +const version = "1.8.0"; export default async function migration() { console.log(`Running setup script ${version}...`); @@ -16,7 +16,7 @@ export default async function migration() { ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true; ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text; ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar; - ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; + ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; COMMIT; `); diff --git a/server/setup/scriptsSqlite/1.8.0.ts b/server/setup/scriptsSqlite/1.8.0.ts index bcee3e8d..f8ac7c95 100644 --- a/server/setup/scriptsSqlite/1.8.0.ts +++ b/server/setup/scriptsSqlite/1.8.0.ts @@ -5,7 +5,7 @@ import path from "path"; const version = "1.8.0"; export default async function migration() { - console.log("Running setup script ${version}..."); + console.log(`Running setup script ${version}...`); const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); @@ -13,7 +13,7 @@ export default async function migration() { try { db.transaction(() => { db.exec(` - ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT true; + ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT 1; ALTER TABLE 'sites' ADD 'remoteSubnets' text; ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text; ALTER TABLE 'user' ADD 'termsVersion' text; From 49981c4beeb7fc55dbf56a3dd512be453e65f6ca Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 18:34:01 -0700 Subject: [PATCH 29/35] Add 21820 to docker --- docker-compose.example.yml | 1 + install/config/docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 5a1b0a4e..c7c068f0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -31,6 +31,7 @@ services: - SYS_MODULE ports: - 51820:51820/udp + - 21820:21820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 35319dd0..4ce31e41 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -31,6 +31,7 @@ services: - SYS_MODULE ports: - 51820:51820/udp + - 21820:21820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode {{end}} From 66f90a542ab252b2488457d80b9efa94527f031f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 18:34:23 -0700 Subject: [PATCH 30/35] Rename to pg --- docker-compose.pgr.yml => docker-compose.pg.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docker-compose.pgr.yml => docker-compose.pg.yml (100%) diff --git a/docker-compose.pgr.yml b/docker-compose.pg.yml similarity index 100% rename from docker-compose.pgr.yml rename to docker-compose.pg.yml From 35823d575135a9e565c028f787e875288bcc893e Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Jul 2025 22:40:27 -0700 Subject: [PATCH 31/35] Fix adding sites to client --- server/routers/client/updateClient.ts | 160 +++++++++++++++++- server/routers/gerbil/peers.ts | 3 +- server/routers/gerbil/updateHolePunch.ts | 142 +++++++++++++--- .../routers/olm/handleOlmRegisterMessage.ts | 18 +- .../[orgId]/settings/clients/create/page.tsx | 14 +- 5 files changed, 293 insertions(+), 44 deletions(-) diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 73c67d53..0dd75186 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, exitNodes, sites } from "@server/db"; import { clients, clientSites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -17,6 +17,7 @@ import { addPeer as olmAddPeer, deletePeer as olmDeletePeer } from "../olm/peers"; +import axios from "axios"; const updateClientParamsSchema = z .object({ @@ -53,6 +54,11 @@ registry.registerPath({ responses: {} }); +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + export async function updateClient( req: Request, res: Response, @@ -124,15 +130,22 @@ export async function updateClient( ); for (const siteId of sitesAdded) { if (!client.subnet || !client.pubKey || !client.endpoint) { - logger.debug("Client subnet, pubKey or endpoint is not set"); + logger.debug( + "Client subnet, pubKey or endpoint is not set" + ); continue; } + // TODO: WE NEED TO HANDLE THIS BETTER. RIGHT NOW WE ARE JUST GUESSING BASED ON THE OTHER SITES + // BUT REALLY WE NEED TO TRACK THE USERS PREFERENCE THAT THEY CHOSE IN THE CLIENTS + const isRelayed = true; + const site = await newtAddPeer(siteId, { publicKey: client.pubKey, allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client - endpoint: client.endpoint + endpoint: isRelayed ? "" : client.endpoint }); + if (!site) { logger.debug("Failed to add peer to newt - missing site"); continue; @@ -142,9 +155,45 @@ export async function updateClient( logger.debug("Site endpoint or publicKey is not set"); continue; } + + let endpoint; + + if (isRelayed) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} has no exit node, skipping` + ); + return null; + } + + // get the exit node for the site + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + + if (!exitNode) { + logger.warn( + `Exit node not found for site ${site.siteId}` + ); + return null; + } + + endpoint = `${exitNode.endpoint}:21820`; + } else { + if (!endpoint) { + logger.warn( + `Site ${site.siteId} has no endpoint, skipping` + ); + return null; + } + endpoint = site.endpoint; + } + await olmAddPeer(client.clientId, { - siteId: siteId, - endpoint: site.endpoint, + siteId: site.siteId, + endpoint: endpoint, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, @@ -171,7 +220,11 @@ export async function updateClient( logger.debug("Site endpoint or publicKey is not set"); continue; } - await olmDeletePeer(client.clientId, site.siteId, site.publicKey); + await olmDeletePeer( + client.clientId, + site.siteId, + site.publicKey + ); } } @@ -202,6 +255,101 @@ export async function updateClient( } } + if (client.endpoint) { + // get all sites for this client and join with exit nodes with site.exitNodeId + const sitesData = await db + .select() + .from(sites) + .innerJoin( + clientSites, + eq(sites.siteId, clientSites.siteId) + ) + .leftJoin( + exitNodes, + eq(sites.exitNodeId, exitNodes.exitNodeId) + ) + .where(eq(clientSites.clientId, client.clientId)); + + let exitNodeDestinations: { + reachableAt: string; + destinations: PeerDestination[]; + }[] = []; + + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn( + `Site ${site.sites.siteId} has no subnet, skipping` + ); + continue; + } + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + destinations: [ + { + destinationIP: + site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + }); + } + + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); + } + + for (const destination of exitNodeDestinations) { + try { + logger.info( + `Updating destinations for exit node at ${destination.reachableAt}` + ); + const payload = { + sourceIp: client.endpoint?.split(":")[0] || "", + sourcePort: parseInt(client.endpoint?.split(":")[1]) || 0, + destinations: destination.destinations + }; + logger.info( + `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` + ); + const response = await axios.post( + `${destination.reachableAt}/update-destinations`, + payload, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error( + `Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}` + ); + } + throw error; + } + } + } + // Fetch the updated client const [updatedClient] = await trx .select() diff --git a/server/routers/gerbil/peers.ts b/server/routers/gerbil/peers.ts index ce378ad4..70c56e04 100644 --- a/server/routers/gerbil/peers.ts +++ b/server/routers/gerbil/peers.ts @@ -8,7 +8,7 @@ export async function addPeer(exitNodeId: number, peer: { publicKey: string; allowedIps: string[]; }) { - + logger.info(`Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}`); const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); @@ -35,6 +35,7 @@ export async function addPeer(exitNodeId: number, peer: { } export async function deletePeer(exitNodeId: number, publicKey: string) { + logger.info(`Deleting peer with public key ${publicKey} from exit node ${exitNodeId}`); const [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.exitNodeId, exitNodeId)).limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 4910738e..6d64249c 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, newts, olms, Site, sites, clientSites } from "@server/db"; +import { clients, newts, olms, Site, sites, clientSites, exitNodes } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; +import axios from "axios"; // Define Zod schema for request validation const updateHolePunchSchema = z.object({ @@ -17,7 +18,8 @@ const updateHolePunchSchema = z.object({ token: z.string(), ip: z.string(), port: z.number(), - timestamp: z.number() + timestamp: z.number(), + reachableAt: z.string().optional() }); // New response type with multi-peer destination support @@ -43,7 +45,7 @@ export async function updateHolePunch( ); } - const { olmId, newtId, ip, port, timestamp, token } = parsedParams.data; + const { olmId, newtId, ip, port, timestamp, token, reachableAt } = parsedParams.data; let currentSiteId: number | undefined; let destinations: PeerDestination[] = []; @@ -94,36 +96,126 @@ export async function updateHolePunch( ); } - // Get all sites that this client is connected to - const clientSitePairs = await db - .select() - .from(clientSites) - .where(eq(clientSites.clientId, client.clientId)); + // // Get all sites that this client is connected to + // const clientSitePairs = await db + // .select() + // .from(clientSites) + // .where(eq(clientSites.clientId, client.clientId)); - if (clientSitePairs.length === 0) { - logger.warn(`No sites found for client: ${client.clientId}`); - return next( - createHttpError(HttpCode.NOT_FOUND, "No sites found for client") - ); - } + // if (clientSitePairs.length === 0) { + // logger.warn(`No sites found for client: ${client.clientId}`); + // return next( + // createHttpError(HttpCode.NOT_FOUND, "No sites found for client") + // ); + // } - // Get all sites details - const siteIds = clientSitePairs.map(pair => pair.siteId); + // // Get all sites details + // const siteIds = clientSitePairs.map(pair => pair.siteId); - for (const siteId of siteIds) { - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)); + // for (const siteId of siteIds) { + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, siteId)); - if (site && site.subnet && site.listenPort) { - destinations.push({ - destinationIP: site.subnet.split("/")[0], - destinationPort: site.listenPort + // if (site && site.subnet && site.listenPort) { + // destinations.push({ + // destinationIP: site.subnet.split("/")[0], + // destinationPort: site.listenPort + // }); + // } + // } + + // get all sites for this client and join with exit nodes with site.exitNodeId + const sitesData = await db + .select() + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(eq(clientSites.clientId, client.clientId)); + + let exitNodeDestinations: { + reachableAt: string; + destinations: PeerDestination[]; + }[] = []; + + for (const site of sitesData) { + if (!site.sites.subnet) { + logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); + continue; + } + // find the destinations in the array + let destinations = exitNodeDestinations.find( + (d) => d.reachableAt === site.exitNodes?.reachableAt + ); + + if (!destinations) { + destinations = { + reachableAt: site.exitNodes?.reachableAt || "", + destinations: [ + { + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 + } + ] + }; + } else { + // add to the existing destinations + destinations.destinations.push({ + destinationIP: site.sites.subnet.split("/")[0], + destinationPort: site.sites.listenPort || 0 }); } + + // update it in the array + exitNodeDestinations = exitNodeDestinations.filter( + (d) => d.reachableAt !== site.exitNodes?.reachableAt + ); + exitNodeDestinations.push(destinations); } + logger.debug(JSON.stringify(exitNodeDestinations, null, 2)); + + for (const destination of exitNodeDestinations) { + // if its the current exit node skip it because it is replying with the same data + if (reachableAt && destination.reachableAt == reachableAt) { + logger.debug(`Skipping update for reachableAt: ${reachableAt}`); + continue; + } + + try { + const response = await axios.post( + `${destination.reachableAt}/update-destinations`, + { + sourceIp: client.endpoint?.split(":")[0] || "", + sourcePort: client.endpoint?.split(":")[1] || 0, + destinations: destination.destinations + }, + { + headers: { + "Content-Type": "application/json" + } + } + ); + + logger.info("Destinations updated:", { + peer: response.data.status + }); + } catch (error) { + if (axios.isAxiosError(error)) { + throw new Error( + `Error communicating with Gerbil. Make sure Pangolin can reach the Gerbil HTTP API: ${error.response?.status}` + ); + } + throw error; + } + } + + // Send the desinations back to the origin + destinations = exitNodeDestinations.find( + (d) => d.reachableAt === reachableAt + )?.destinations || []; + } else if (newtId) { logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`); diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 8a73daff..32e4fe51 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -104,6 +104,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // Prepare an array to store site configurations let siteConfigurations = []; logger.debug(`Found ${sitesData.length} sites for client ${client.clientId}`); + + if (sitesData.length === 0) { + sendToClient(olm.olmId, { + type: "olm/register/no-sites", + data: {} + }); + } + // Process each site for (const { sites: site } of sitesData) { if (!site.exitNodeId) { @@ -180,11 +188,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { }); } - // If we have no valid site configurations, don't send a connect message - if (siteConfigurations.length === 0) { - logger.warn("No valid site configurations found"); - return; - } + // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES + // if (siteConfigurations.length === 0) { + // logger.warn("No valid site configurations found"); + // return; + // } // Return connect message with all site configurations return { diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx index 88d2bef2..24fbe027 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -147,33 +147,33 @@ export default function Page() { mac: { "Apple Silicon (arm64)": [ `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_arm64" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ], "Intel x64 (amd64)": [ `curl -L -o olm "https://github.com/fosrl/olm/releases/download/${version}/olm_darwin_amd64" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ] }, linux: { amd64: [ `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_amd64" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ], arm64: [ `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm64" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ], arm32: [ `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ], arm32v6: [ `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_arm32v6" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ], riscv64: [ `wget -O olm "https://github.com/fosrl/olm/releases/download/${version}/olm_linux_riscv64" && chmod +x ./olm`, - `./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` + `sudo ./olm --id ${id} --secret ${secret} --endpoint ${endpoint}` ] }, windows: { From 8fdb3ea63193ee560fc65f15e67ea6c49d48535b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 29 Jul 2025 10:46:01 -0700 Subject: [PATCH 32/35] hide favicon --- src/app/[orgId]/MemberResourcesPortal.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/[orgId]/MemberResourcesPortal.tsx b/src/app/[orgId]/MemberResourcesPortal.tsx index ad412b1e..4d3a7717 100644 --- a/src/app/[orgId]/MemberResourcesPortal.tsx +++ b/src/app/[orgId]/MemberResourcesPortal.tsx @@ -630,12 +630,6 @@ export default function MemberResourcesPortal({

-
- -
From 1cca06a27493e1ad570d190d8cf8a495bd530a5c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 29 Jul 2025 23:09:49 -0700 Subject: [PATCH 33/35] Add note about wintun --- src/app/[orgId]/settings/clients/create/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/create/page.tsx b/src/app/[orgId]/settings/clients/create/page.tsx index 24fbe027..00b6b34c 100644 --- a/src/app/[orgId]/settings/clients/create/page.tsx +++ b/src/app/[orgId]/settings/clients/create/page.tsx @@ -178,7 +178,8 @@ export default function Page() { }, windows: { x64: [ - `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_amd64.exe"`, + `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`, + `# Run the installer to install olm and wintun`, `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` ] } From 8a250d10114643deab3df51fadd620c2d93dddb0 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 30 Jul 2025 10:23:44 -0700 Subject: [PATCH 34/35] rm YC --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index f3214b6f..8c94815d 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,6 @@ _Pangolin tunnels your services to the internet so you can access anything from

-

- Launch YC: Pangolin – Open-source secure gateway to private networks -

- Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. Preview From bb15af9954c2b10efa4568f815cc1a5732ff652b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 30 Jul 2025 10:23:52 -0700 Subject: [PATCH 35/35] Add ports warn at start --- install/main.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/install/main.go b/install/main.go index 8160f2e9..9bb0c7e1 100644 --- a/install/main.go +++ b/install/main.go @@ -60,8 +60,23 @@ const ( ) func main() { + + // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking + + fmt.Println("Welcome to the Pangolin installer!") + fmt.Println("This installer will help you set up Pangolin on your server.") + fmt.Println("") + fmt.Println("Please make sure you have the following prerequisites:") + fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.") + fmt.Println("- Point your domain to the VPS IP with A records.") + fmt.Println("") + fmt.Println("http://docs.fossorial.io/Getting%20Started/dns-networking") + fmt.Println("") + fmt.Println("Lets get started!") + fmt.Println("") + reader := bufio.NewReader(os.Stdin) - inputContainer := readString(reader, "Would you like to run Pangolin as docker or podman container?", "docker") + inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") chosenContainer := Docker if strings.EqualFold(inputContainer, "docker") {