From a03916821728b72de88e332fdff4b6343049485c Mon Sep 17 00:00:00 2001 From: Julian <130256240+j4n-e4t@users.noreply.github.com> Date: Thu, 16 Jan 2025 21:15:41 +0100 Subject: [PATCH 01/19] add ability to transfer a resource to another site --- server/routers/external.ts | 7 + server/routers/resource/index.ts | 1 + server/routers/resource/transferResource.ts | 84 +++++++++ .../resources/[resourceId]/general/page.tsx | 168 ++++++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 server/routers/resource/transferResource.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 0910f07d..b63f982d 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -308,6 +308,13 @@ authenticated.get( resource.getResourceWhitelist ); +authenticated.post( + `/resource/:resourceId/transfer`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.transferResource +); + authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 7dbee1bf..6bde1a83 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,3 +16,4 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./transferResource"; diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts new file mode 100644 index 00000000..91cb9774 --- /dev/null +++ b/server/routers/resource/transferResource.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const transferResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const transferResourceBodySchema = z + .object({ + siteId: z.number().int().positive() + }) + .strict(); + +export async function transferResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = transferResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = transferResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { siteId } = parsedBody.data; + + const [updatedResource] = await db + .update(resources) + .set({ siteId }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + if (!updatedResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + return response(res, { + data: updatedResource, + success: true, + error: false, + message: "Resource transferred successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 6d3e5777..219854f3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -23,6 +23,7 @@ import { CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@app/lib/cn"; import { Popover, @@ -60,7 +61,12 @@ const GeneralFormSchema = z.object({ // siteId: z.number(), }); +const TransferFormSchema = z.object({ + siteId: z.number() +}); + type GeneralFormValues = z.infer; +type TransferFormValues = z.infer; export default function GeneralForm() { const params = useParams(); @@ -76,6 +82,8 @@ export default function GeneralForm() { const [sites, setSites] = useState([]); const [saveLoading, setSaveLoading] = useState(false); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); + const [transferLoading, setTransferLoading] = useState(false); + const [open, setOpen] = useState(false); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -87,6 +95,13 @@ export default function GeneralForm() { mode: "onChange" }); + const transferForm = useForm({ + resolver: zodResolver(TransferFormSchema), + defaultValues: { + siteId: resource.siteId ? Number(resource.siteId) : undefined + } + }); + useEffect(() => { const fetchSites = async () => { const res = await api.get>( @@ -131,6 +146,33 @@ export default function GeneralForm() { .finally(() => setSaveLoading(false)); } + async function onTransfer(data: TransferFormValues) { + setTransferLoading(true); + + api.post(`resource/${resource?.resourceId}/transfer`, { + siteId: data.siteId + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to transfer resource", + description: formatAxiosError( + e, + "An error occurred while transferring the resource" + ) + }); + }) + .then(() => { + toast({ + title: "Resource transferred", + description: + "The resource has been transferred successfully" + }); + router.refresh(); + }) + .finally(() => setTransferLoading(false)); + } + return ( @@ -212,6 +254,132 @@ export default function GeneralForm() { + + + + + Transfer Resource + + + Transfer this resource to a different site + + + + + +
+ + ( + + + Destination Site + + + + + + + + + + + + No sites found. + + + {sites.map( + (site) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + Select the site you want to + transfer this resource to + + + + )} + /> + + +
+
+ + + + +
); } From 81571a8fb72d0af371270ba7cca1ba5c7b8cd73b Mon Sep 17 00:00:00 2001 From: synologyy <137535302+synologyy@users.noreply.github.com> Date: Fri, 31 Jan 2025 09:01:00 +0100 Subject: [PATCH 02/19] german-translation --- internationalization/de.md | 267 +++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 internationalization/de.md diff --git a/internationalization/de.md b/internationalization/de.md new file mode 100644 index 00000000..1acd5b12 --- /dev/null +++ b/internationalization/de.md @@ -0,0 +1,267 @@ +## Login site + +| EN | DE | Notes | +| --------------------- | ---------------------------------- | ----------- | +| Welcome to Pangolin | Willkommen bei Pangolin | | +| Log in to get started | Melden Sie sich an, um zu beginnen | | +| Email | E-Mail | | +| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder | +| Password | Passwort | | +| Enter your password | Geben Sie Ihr Passwort ein | placeholder | +| Forgot your password? | Passwort vergessen? | | +| Log in | Anmelden | | + +# Ogranization site after successful login + +| EN | DE | Notes | +| ----------------------------------------- | -------------------------------------------- | ----- | +| Welcome to Pangolin | Willkommen bei Pangolin | | +| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | | + +## Shared Header, Navbar and Footer +##### Header + +| EN | DE | Notes | +| ------------------- | ------------------- | ----- | +| Documentation | Dokumentation | | +| Support | Support | | +| Organization {name} | Organisation {name} | | +##### Organization selector + +| EN | DE | Notes | +| ---------------- | ----------------- | ----- | +| Search… | Suchen… | | +| Create | Erstellen | | +| New Organization | Neue Organisation | | +| Organizations | Organisationen | | + +##### Navbar + +| EN | DE | Notes | +| --------------- | ----------------- | ----- | +| Sites | Websites | | +| Resources | Ressourcen | | +| User & Roles | Benutzer & Rollen | | +| Shareable Links | Teilbare Links | | +| General | Allgemein | | +##### Footer +| EN | DE | | +| ------------------------- | --------------------------- | ------------------- | +| Page {number} of {number} | Seite {number} von {number} | | +| Rows per page | Zeilen pro Seite | | +| Pangolin | Pangolin | unten auf der Seite | +| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite | +| Open Source | Open Source | unten auf der Seite | +| Documentation | Dokumentation | unten auf der Seite | +| {version} | {version} | unten auf der Seite | + +## Main “Sites” +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Newt (Recommended) | Newt (empfohlen) | | +| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | | +| Runs in Docker | Läuft in Docker | | +| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | | +| Install Newt | Newt installieren | | +| Basic WireGuard
| Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | | +| Compatible with all WireGuard clients
| Kompatibel mit allen WireGuard-Clients
| | +| Manual configuration required | Manuelle Konfiguration erforderlich
| | +##### Content + +| EN | DE | Notes | +| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- | +| Manage Sites | Seiten verwalten | | +| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | | +| Search sites | Seiten suchen | placeholder | +| Add Site | Seite hinzufügen | | +| Name | Name | table header | +| Online | Status | table header | +| Site | Seite | table header | +| Data In | Eingehende Daten | table header | +| Data Out | Ausgehende Daten | table header | +| Connection Type | Verbindungstyp | table header | +| Online | Online | site state | +| Offline | Offline | site state | +| Edit → | Bearbeiten → | | +| View settings | Einstellungen anzeigen | Popup after clicking “…” on site | +| Delete | Löschen | Popup after clicking “…” on site | +##### Add Site Popup + +| EN | DE | Notes | +| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- | +| Create Site | Seite erstellen | | +| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | | +| Name | Name | | +| Site name | Seiten-Name | placeholder | +| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc | +| Method | Methode | | +| Local | Lokal | | +| Newt | Newt | | +| WireGuard | WireGuard | | +| This is how you will expose connections. | So werden Verbindungen freigegeben. | | +| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | | +| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | | +| I have copied the config | Ich habe die Konfiguration kopiert | | +| Create Site | Website erstellen | | +| Close | Schließen | | + +## Main “Resources” + +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Resources | Ressourcen | | +| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | | +| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | | +| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | | +| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | | +##### Content + +| EN | DE | Notes | +| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- | +| Manage Resources | Ressourcen verwalten | | +| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | | +| Search resources | Ressourcen durchsuchen | placeholder | +| Name | Name | | +| Site | Website | | +| Full URL | Vollständige URL | | +| Authentication | Authentifizierung | | +| Not Protected | Nicht geschützt | authentication state | +| Protected | Geschützt | authentication state | +| Edit → | Bearbeiten → | | +| Add Resource | Ressource hinzufügen | | +##### Add Resource Popup + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- | +| Create Resource | Ressource erstellen | | +| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | | +| Name | Name | | +| My Resource | Neue Ressource | name placeholder | +| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | | +| Subdomain | Subdomain | | +| Enter subdomain | Subdomain eingeben | | +| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | | +| Site | Website | | +| Search site… | Website suchen… | Site selector popup | +| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | | +| Create Resource | Ressource erstellen | | +| Close | Schließen | | + + +## Main “User & Roles” +##### Content + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- | +| Manage User & Roles | Benutzer & Rollen verwalten | | +| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | | +| Users | Benutzer | sidebar item | +| Roles | Rollen | sidebar item | +| **User tab** | | | +| Search users | Benutzer suchen | placeholder | +| Invite User | Benutzer einladen | addbutton | +| Email | E-Mail | table header | +| Status | Status | table header | +| Role | Rolle | table header | +| Confirmed | Bestätigt | account status | +| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status | +| Owner | Besitzer | role | +| Admin | Administrator | role | +| Member | Mitglied | role | +| **Roles Tab** | | | +| Search roles | Rollen suchen | placeholder | +| Add Role | Rolle hinzufügen | addbutton | +| Name | Name | table header | +| Description | Beschreibung | table header | +| Admin | Administrator | role | +| Member | Mitglied | role | +| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc | +| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc | + +##### Invite User popup + +| EN | DE | Notes | +| ----------------- | ------------------------------------------------------- | ----------- | +| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | | +| Email | E-Mail | | +| Enter an email | E-Mail eingeben | placeholder | +| Role | Rolle | | +| Select role | Rolle auswählen | placeholder | +| Gültig für | Gültig bis | | +| 1 day | Tag | | +| 2 days | 2 Tage | | +| 3 days | 3 Tage | | +| 4 days | 4 Tage | | +| 5 days | 5 Tage | | +| 6 days | 6 Tage | | +| 7 days | 7 Tage | | +| Create Invitation | Einladung erstellen | | +| Close | Schließen | | + + +## Main “Shareable Links” +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Shareable Links | Teilbare Links | | +| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | | +| Easy to create and share | Einfach zu erstellen und zu teilen | | +| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | | +| Secure and revocable | Sicher und widerrufbar | | +##### Content + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- | +| Manage Shareable Links | Teilbare Links verwalten | | +| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | | +| Search links | Links suchen | placeholder | +| Create Share Link | Neuen Link erstellen | addbutton | +| Resource | Ressource | table header | +| Title | Titel | table header | +| Created | Erstellt | table header | +| Expires | Gültig bis | table header | +| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder | + +##### Create Shareable Link popup + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- | +| Create Shareable Link | Teilbaren Link erstellen | | +| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | | +| Resource | Ressource | | +| Select resource | Ressource auswählen | | +| Search resources… | Ressourcen suchen… | resource selector popup | +| Title (optional) | Titel (optional) | | +| Enter title | Titel eingeben | placeholder | +| Expire in | Gültig bis | | +| Minutes | Minuten | | +| Hours | Stunden | | +| Days | Tage | | +| Months | Monate | | +| Years | Jahre | | +| Never expire | Nie ablaufen | | +| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | | +| Create Link | Link erstellen | | +| Close | Schließen | | + + +## Main “General” + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ | +| General | Allgemein | | +| Configure your organization’s general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | | +| General | Allgemein | sidebar item | +| Organization Settings | Organisationseinstellungen | | +| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | | +| Name | Name | | +| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | | +| Save Settings | Einstellungen speichern | | +| Danger Zone | Gefahrenzone | | +| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | | +| Delete Organization Data | Organisationsdaten löschen | | \ No newline at end of file From 8b43c6f9c58113700cf9284fe89bfc2cdb1fc899 Mon Sep 17 00:00:00 2001 From: Kamil <32385799+nkkfs@users.noreply.github.com> Date: Sat, 1 Feb 2025 08:56:20 +0100 Subject: [PATCH 03/19] Update pl.md Add Authentication Site strings --- internationalization/pl.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internationalization/pl.md b/internationalization/pl.md index 4414a908..a55866e2 100644 --- a/internationalization/pl.md +++ b/internationalization/pl.md @@ -1,3 +1,23 @@ +## Authentication Site + + +| EN | PL | Notes | +| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- | +| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | | +| Authentication Required | Wymagane uwierzytelnienie | | +| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | | +| PIN | PIN | | +| User | Zaloguj | | +| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login | +| Login in with PIN | Zaloguj się PIN’em | pin login | +| Email | Email | user login | +| Enter your email | Wprowadź swój email | user login | +| Password | Hasło | user login | +| Enter your password | Wprowadź swoje hasło | user login | +| Forgot your password? | Zapomniałeś hasła? | user login | +| Log in | Zaloguj | user login | + + ## Login site | EN | PL | Notes | From dc9b1f1efd4c9a0554cddd9b5fbddc040933499a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 1 Feb 2025 16:52:18 -0500 Subject: [PATCH 04/19] add project board to readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6911213f..2c887483 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) [![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) -Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. +Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. ### Installation and Documentation @@ -129,6 +129,10 @@ Pangolin was inspired by several existing projects and concepts: - **Authentik and Authelia**: These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. +## Project Development / Roadmap + +Pangolin is under active development, and we are continuously adding new features and improvements. View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info. + ## Licensing Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us. From b5420a40ab401bf9b67c9e10129ce449a1193c67 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 1 Feb 2025 18:36:12 -0500 Subject: [PATCH 05/19] Clean up and add target manipulation --- server/routers/gerbil/getConfig.ts | 16 +-- server/routers/newt/targets.ts | 8 +- server/routers/resource/deleteResource.ts | 19 +-- server/routers/resource/transferResource.ts | 110 +++++++++++++++++- server/routers/target/createTarget.ts | 2 +- server/routers/target/deleteTarget.ts | 19 +-- .../routers/target/{ports.ts => helpers.ts} | 18 +++ server/routers/target/updateTarget.ts | 2 +- .../resources/[resourceId]/general/page.tsx | 65 ++++++----- 9 files changed, 172 insertions(+), 87 deletions(-) rename server/routers/target/{ports.ts => helpers.ts} (71%) diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 314e715a..28b576d8 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -11,6 +11,7 @@ import config from "@server/lib/config"; import { getUniqueExitNodeEndpointName } from '@server/db/names'; import { findNextAvailableCidr } from "@server/lib/ip"; import { fromError } from 'zod-validation-error'; +import { getAllowedIps } from '../target/helpers'; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), @@ -83,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) }); const peers = await Promise.all(sitesRes.map(async (site) => { - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId), - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all(resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId), - }); - return targetsRes.map(target => `${target.ip}/32`); - })); - return { publicKey: site.pubKey, - allowedIps: targetIps.flat(), + allowedIps: await getAllowedIps(site.siteId) }; })); diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index e5f7855c..2c1143e6 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,11 +1,11 @@ import { Target } from "@server/db/schema"; import { sendToClient } from "../ws"; -export async function addTargets( +export function addTargets( newtId: string, targets: Target[], protocol: string -): Promise { +) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ @@ -22,11 +22,11 @@ export async function addTargets( sendToClient(newtId, payload); } -export async function removeTargets( +export function removeTargets( newtId: string, targets: Target[], protocol: string -): Promise { +) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index ed0fc95f..8acf0d77 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { removeTargets } from "../newt/targets"; +import { getAllowedIps } from "../target/helpers"; // Define Zod schema for request parameters validation const deleteResourceSchema = z @@ -75,25 +76,9 @@ export async function deleteResource( if (site.pubKey) { if (site.type == "wireguard") { - // TODO: is this all inefficient? - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); - await addPeer(site.exitNodeId!, { publicKey: site.pubKey, - allowedIps: targetIps.flat() + allowedIps: await getAllowedIps(site.siteId) }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index 91cb9774..31777c30 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -1,13 +1,16 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources } from "@server/db/schema"; +import { newts, resources, sites, targets } from "@server/db/schema"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { addPeer } from "../gerbil/peers"; +import { addTargets, removeTargets } from "../newt/targets"; +import { getAllowedIps } from "../target/helpers"; const transferResourceParamsSchema = z .object({ @@ -53,6 +56,60 @@ export async function transferResource( const { resourceId } = parsedParams.data; const { siteId } = parsedBody.data; + const [oldResource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!oldResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (oldResource.siteId === siteId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Resource is already assigned to site with ID ${siteId}` + ) + ); + } + + const [newSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!newSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + const [oldSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, oldResource.siteId)) + .limit(1); + + if (!oldSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${oldResource.siteId} not found` + ) + ); + } + const [updatedResource] = await db .update(resources) .set({ siteId }) @@ -68,6 +125,57 @@ export async function transferResource( ); } + const resourceTargets = await db + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); + + if (resourceTargets.length > 0) { + ////// REMOVE THE TARGETS FROM THE OLD SITE ////// + if (oldSite.pubKey) { + if (oldSite.type == "wireguard") { + await addPeer(oldSite.exitNodeId!, { + publicKey: oldSite.pubKey, + allowedIps: await getAllowedIps(oldSite.siteId) + }); + } else if (oldSite.type == "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, oldSite.siteId)) + .limit(1); + + removeTargets( + newt.newtId, + resourceTargets, + updatedResource.protocol + ); + } + } + + ////// ADD THE TARGETS TO THE NEW SITE ////// + if (newSite.pubKey) { + if (newSite.type == "wireguard") { + await addPeer(newSite.exitNodeId!, { + publicKey: newSite.pubKey, + allowedIps: await getAllowedIps(newSite.siteId) + }); + } else if (newSite.type == "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, newSite.siteId)) + .limit(1); + + addTargets( + newt.newtId, + resourceTargets, + updatedResource.protocol + ); + } + } + } + return response(res, { data: updatedResource, success: true, diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 3d5e8d0e..b1080d87 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -11,7 +11,7 @@ import { isIpInCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; -import { pickPort } from "./ports"; +import { pickPort } from "./helpers"; // Regular expressions for validation const DOMAIN_REGEX = diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 97dab71c..7472b73d 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; +import { getAllowedIps } from "./helpers"; const deleteTargetSchema = z .object({ @@ -80,25 +81,9 @@ export async function deleteTarget( if (site.pubKey) { if (site.type == "wireguard") { - // TODO: is this all inefficient? - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); - await addPeer(site.exitNodeId!, { publicKey: site.pubKey, - allowedIps: targetIps.flat() + allowedIps: await getAllowedIps(site.siteId) }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/target/ports.ts b/server/routers/target/helpers.ts similarity index 71% rename from server/routers/target/ports.ts rename to server/routers/target/helpers.ts index bfa8f280..606e2290 100644 --- a/server/routers/target/ports.ts +++ b/server/routers/target/helpers.ts @@ -46,3 +46,21 @@ export async function pickPort(siteId: number): Promise<{ return { internalPort, targetIps }; } + +export async function getAllowedIps(siteId: number) { + // TODO: is this all inefficient? + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, siteId) + }); + + // Fetch targets for all resources of this site + const targetIps = await Promise.all( + resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId) + }); + return targetsRes.map((target) => `${target.ip}/32`); + }) + ); + return targetIps.flat(); +} diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4125fd9c..2ae6222d 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,7 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { pickPort } from "./ports"; +import { pickPort } from "./helpers"; // Regular expressions for validation const DOMAIN_REGEX = diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index bfcaa134..b4b99eef 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -19,7 +19,7 @@ import { CommandEmpty, CommandGroup, CommandInput, - CommandItem, + CommandItem } from "@/components/ui/command"; import { cn } from "@app/lib/cn"; import { @@ -144,14 +144,15 @@ export default function GeneralForm() { async function onSubmit(data: GeneralFormValues) { setSaveLoading(true); - api.post>( - `resource/${resource?.resourceId}`, - { - name: data.name, - subdomain: data.subdomain - // siteId: data.siteId, - } - ) + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + name: data.name, + subdomain: data.subdomain + // siteId: data.siteId, + } + ) .catch((e) => { toast({ variant: "destructive", @@ -161,26 +162,26 @@ export default function GeneralForm() { "An error occurred while updating the resource" ) }); - }) - .then(() => { - toast({ - title: "Resource updated", - description: "The resource has been updated successfully" - }); + }); - updateResource({ name: data.name, subdomain: data.subdomain }); + if (res && res.status === 200) { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully" + }); - router.refresh(); - }) - .finally(() => setSaveLoading(false)); + updateResource({ name: data.name, subdomain: data.subdomain }); + } + setSaveLoading(false); } async function onTransfer(data: TransferFormValues) { setTransferLoading(true); - api.post(`resource/${resource?.resourceId}/transfer`, { - siteId: data.siteId - }) + const res = await api + .post(`resource/${resource?.resourceId}/transfer`, { + siteId: data.siteId + }) .catch((e) => { toast({ variant: "destructive", @@ -190,16 +191,16 @@ export default function GeneralForm() { "An error occurred while transferring the resource" ) }); - }) - .then(() => { - toast({ - title: "Resource transferred", - description: - "The resource has been transferred successfully" - }); - router.refresh(); - }) - .finally(() => setTransferLoading(false)); + }); + + if (res && res.status === 200) { + toast({ + title: "Resource transferred", + description: "The resource has been transferred successfully" + }); + router.refresh(); + } + setTransferLoading(false); } return ( From 53660a163c85d53e20c3cc0915205d36cb7b00d0 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 1 Feb 2025 21:11:31 -0500 Subject: [PATCH 06/19] minor changes to verbiage and id value --- server/routers/resource/transferResource.ts | 2 +- .../[orgId]/settings/resources/[resourceId]/general/page.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index 31777c30..69c9a2a6 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -75,7 +75,7 @@ export async function transferResource( return next( createHttpError( HttpCode.BAD_REQUEST, - `Resource is already assigned to site with ID ${siteId}` + `Resource is already assigned to this site` ) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index b4b99eef..3aa9e761 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -396,7 +396,7 @@ export default function GeneralForm() { (site) => ( - Select the site you want to - transfer this resource to + Select the new site to transfer this resource to. From 9d3619845946ef6d4a7605b11f2fdac76c94bd92 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 1 Feb 2025 21:19:24 -0500 Subject: [PATCH 07/19] fix search id value in command items --- src/app/[orgId]/settings/resources/CreateResourceForm.tsx | 2 +- src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index abe5608c..17c115f3 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -465,7 +465,7 @@ export default function CreateResourceForm({ ) => ( ( Date: Sun, 2 Feb 2025 15:30:41 -0500 Subject: [PATCH 08/19] pull app version from consts instead of package.json --- package.json | 2 +- server/lib/config.ts | 7 ++----- server/lib/consts.ts | 4 +++- server/lib/loadAppVersion.ts | 16 ---------------- server/setup/migrations.ts | 8 ++------ 5 files changed, 8 insertions(+), 29 deletions(-) delete mode 100644 server/lib/loadAppVersion.ts diff --git a/package.json b/package.json index eceba242..74f75f79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.10", + "version": "0.0.0", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/lib/config.ts b/server/lib/config.ts index 14e96af1..f2d8a161 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -6,6 +6,7 @@ import { fromError } from "zod-validation-error"; import { __DIRNAME, APP_PATH, + APP_VERSION, configFilePath1, configFilePath2 } from "@server/lib/consts"; @@ -239,11 +240,7 @@ export class Config { throw new Error(`Invalid configuration file: ${errors}`); } - const appVersion = loadAppVersion(); - if (!appVersion) { - throw new Error("Could not load the application version"); - } - process.env.APP_VERSION = appVersion; + process.env.APP_VERSION = APP_VERSION; process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString(); process.env.SERVER_EXTERNAL_PORT = diff --git a/server/lib/consts.ts b/server/lib/consts.ts index a444f9c5..2f505ae1 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -1,6 +1,8 @@ import path from "path"; import { fileURLToPath } from "url"; -import { existsSync } from "fs"; + +// This is a placeholder value replaced by the build process +export const APP_VERSION = "1.0.0-beta.12"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/loadAppVersion.ts b/server/lib/loadAppVersion.ts deleted file mode 100644 index 80d9f558..00000000 --- a/server/lib/loadAppVersion.ts +++ /dev/null @@ -1,16 +0,0 @@ -import path from "path"; -import { __DIRNAME } from "@server/lib/consts"; -import fs from "fs"; - -export function loadAppVersion() { - const packageJsonPath = path.join("package.json"); - let packageJson: any; - if (fs.existsSync && fs.existsSync(packageJsonPath)) { - const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8"); - packageJson = JSON.parse(packageJsonContent); - - if (packageJson.version) { - return packageJson.version; - } - } -} diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index b06f176c..e0e25f15 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -3,8 +3,7 @@ import db, { exists } from "@server/db"; import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; -import { __DIRNAME } from "@server/lib/consts"; -import { loadAppVersion } from "@server/lib/loadAppVersion"; +import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; @@ -34,10 +33,7 @@ await runMigrations(); export async function runMigrations() { try { - const appVersion = loadAppVersion(); - if (!appVersion) { - throw new Error("APP_VERSION is not set in the environment"); - } + const appVersion = APP_VERSION; if (exists) { await executeScripts(); From 6a8132546e99700ecdf3d48343fa5896a5228a9c Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 2 Feb 2025 15:36:43 -0500 Subject: [PATCH 09/19] reset create resource form on dialog close closes #145 --- .../[orgId]/settings/resources/CreateResourceForm.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 17c115f3..4add3044 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -129,9 +129,7 @@ export default function CreateResourceForm({ const [sites, setSites] = useState([]); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); - const [showSnippets, setShowSnippets] = useState(false); - const [resourceId, setResourceId] = useState(null); const form = useForm({ @@ -144,11 +142,20 @@ export default function CreateResourceForm({ } }); + function reset() { + form.reset(); + setSites([]); + setShowSnippets(false); + setResourceId(null); + } + useEffect(() => { if (!open) { return; } + reset(); + const fetchSites = async () => { const res = await api.get>( `/org/${orgId}/sites/` From a7c99b016cde9ef666db38877c91900008ecca32 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 2 Feb 2025 15:47:29 -0500 Subject: [PATCH 10/19] prevent raw tcp on port 80 or 443 --- server/routers/resource/createResource.ts | 18 +++++++++--------- .../[resourceId]/connectivity/page.tsx | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e687cc02..473b5708 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -133,15 +133,6 @@ export async function createResource( ) ); - if (existingResource.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Resource with that protocol and port already exists" - ) - ); - } - } else { if (proxyPort === 443 || proxyPort === 80) { return next( createHttpError( @@ -151,6 +142,15 @@ export async function createResource( ); } + if (existingResource.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that protocol and port already exists" + ) + ); + } + } else { // make sure the full domain is unique const existingResource = await db .select() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 3c2b54c6..cf13ea99 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -132,7 +132,7 @@ export default function ReverseProxyTargets(props: { defaultValues: { ip: "", method: resource.http ? "http" : null, - port: resource.http ? 80 : resource.proxyPort || 1234 + port: "" // protocol: "TCP", } }); From 65a537a6705879815e1b3b59fe4710067a94ee89 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 2 Feb 2025 16:03:10 -0500 Subject: [PATCH 11/19] make update raw resource port functional --- server/routers/resource/updateResource.ts | 51 +++++++++++++++---- .../resources/[resourceId]/general/page.tsx | 26 +++++----- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 9a80fb7d..fea80da2 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs, resources, sites } from "@server/db/schema"; -import { eq, or } from "drizzle-orm"; +import { eq, or, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -63,13 +63,16 @@ export async function updateResource( const { resourceId } = parsedParams.data; const updateData = parsedBody.data; - const resource = await db + const [result] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .leftJoin(orgs, eq(resources.orgId, orgs.orgId)); - if (resource.length === 0) { + const resource = result.resources; + const org = result.orgs; + + if (!resource || !org) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -78,7 +81,41 @@ export async function updateResource( ); } - if (!resource[0].orgs?.domain) { + if (updateData.proxyPort) { + const proxyPort = updateData.proxyPort; + const existingResource = await db + .select() + .from(resources) + .where( + and( + eq(resources.protocol, resource.protocol), + eq(resources.proxyPort, proxyPort!) + ) + ); + + if (proxyPort === 443 || proxyPort === 80) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Port 80 and 443 are reserved for https resources" + ) + ); + } + + if ( + existingResource.length > 0 && + existingResource[0].resourceId !== resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that protocol and port already exists" + ) + ); + } + } + + if (!org?.domain) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -88,7 +125,7 @@ export async function updateResource( } const fullDomain = updateData.subdomain - ? `${updateData.subdomain}.${resource[0].orgs.domain}` + ? `${updateData.subdomain}.${org.domain}` : undefined; const updatePayload = { @@ -111,10 +148,6 @@ export async function updateResource( ); } - if (resource[0].resources.ssl !== updatedResource[0].ssl) { - // invalidate all sessions? - } - return response(res, { data: updatedResource[0], success: true, diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 3aa9e761..a6987e92 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -145,14 +145,11 @@ export default function GeneralForm() { setSaveLoading(true); const res = await api - .post>( - `resource/${resource?.resourceId}`, - { - name: data.name, - subdomain: data.subdomain - // siteId: data.siteId, - } - ) + .post(`resource/${resource?.resourceId}`, { + name: data.name, + subdomain: data.subdomain, + proxyPort: data.proxyPort + }) .catch((e) => { toast({ variant: "destructive", @@ -170,7 +167,11 @@ export default function GeneralForm() { description: "The resource has been updated successfully" }); - updateResource({ name: data.name, subdomain: data.subdomain }); + updateResource({ + name: data.name, + subdomain: data.subdomain, + proxyPort: data.proxyPort + }); } setSaveLoading(false); } @@ -395,9 +396,7 @@ export default function GeneralForm() { {sites.map( (site) => ( - Select the new site to transfer this resource to. + Select the new site to transfer + this resource to. From 0840c166abd524c917429eed791591cff24f4a54 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 2 Feb 2025 16:22:00 -0500 Subject: [PATCH 12/19] prevent api resource updates if raw resources is disabled --- server/routers/resource/createResource.ts | 34 +++++++++++++++++------ server/routers/resource/updateResource.ts | 34 ++++++++++++++++------- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 473b5708..a5669a1d 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -18,6 +18,7 @@ import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import config from "@server/lib/config"; const createResourceParamsSchema = z .object({ @@ -63,6 +64,30 @@ const createResourceSchema = z message: "Invalid subdomain", path: ["subdomain"] } + ) + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_raw_resources) { + if (data.proxyPort !== undefined) { + return false; + } + } + return true; + }, + { + message: "Cannot update proxyPort" + } + ) + .refine( + (data) => { + if (data.proxyPort === 443 || data.proxyPort === 80) { + return false; + } + return true; + }, + { + message: "Port 80 and 443 are reserved for http and https resources" + } ); export type CreateResourceResponse = Resource; @@ -133,15 +158,6 @@ export async function createResource( ) ); - if (proxyPort === 443 || proxyPort === 80) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Port 80 and 443 are reserved for https resources" - ) - ); - } - if (existingResource.length > 0) { return next( createHttpError( diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index fea80da2..6910bd76 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { subdomainSchema } from "@server/schemas/subdomainSchema"; +import config from "@server/lib/config"; const updateResourceParamsSchema = z .object({ @@ -32,7 +33,29 @@ const updateResourceBodySchema = z .strict() .refine((data) => Object.keys(data).length > 0, { message: "At least one field must be provided for update" - }); + }) + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_raw_resources) { + if (data.proxyPort !== undefined) { + return false; + } + } + return true; + }, + { message: "Cannot update proxyPort" } + ) + .refine( + (data) => { + if (data.proxyPort === 443 || data.proxyPort === 80) { + return false; + } + return true; + }, + { + message: "Port 80 and 443 are reserved for http and https resources" + } + ); export async function updateResource( req: Request, @@ -93,15 +116,6 @@ export async function updateResource( ) ); - if (proxyPort === 443 || proxyPort === 80) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Port 80 and 443 are reserved for https resources" - ) - ); - } - if ( existingResource.length > 0 && existingResource[0].resourceId !== resourceId From e475c1ea50b51d7c6f951ef33cd0c2671dd81e3d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 3 Feb 2025 21:18:16 -0500 Subject: [PATCH 13/19] all resources at the base domain closes #137 --- server/db/schema.ts | 3 +- server/lib/config.ts | 14 +- server/routers/resource/createResource.ts | 33 +++- server/routers/resource/updateResource.ts | 62 ++++++- server/routers/traefik/getTraefikConfig.ts | 7 +- server/setup/copyInConfig.ts | 7 +- server/setup/migrations.ts | 44 ++++- server/setup/scripts/1.0.0-beta12.ts | 62 +++++++ .../settings/resources/CreateResourceForm.tsx | 149 ++++++++++++----- .../[resourceId]/ResourceInfoBox.tsx | 19 ++- .../[resourceId]/connectivity/page.tsx | 3 +- .../resources/[resourceId]/general/page.tsx | 154 +++++++++++++----- src/components/ui/checkbox.tsx | 69 +++++--- src/lib/pullEnv.ts | 10 +- src/lib/types/env.ts | 1 + 15 files changed, 496 insertions(+), 141 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta12.ts diff --git a/server/db/schema.ts b/server/db/schema.ts index b87acd91..f44873d1 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -53,7 +53,8 @@ export const resources = sqliteTable("resources", { proxyPort: integer("proxyPort"), emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + isBaseDomain: integer("isBaseDomain", { mode: "boolean" }) }); export const targets = sqliteTable("targets", { diff --git a/server/lib/config.ts b/server/lib/config.ts index f2d8a161..b8e61617 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -10,7 +10,6 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; -import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; @@ -152,7 +151,8 @@ const configSchema = z.object({ require_email_verification: z.boolean().optional(), disable_signup_without_invite: z.boolean().optional(), disable_user_create_org: z.boolean().optional(), - allow_raw_resources: z.boolean().optional() + allow_raw_resources: z.boolean().optional(), + allow_base_domain_resources: z.boolean().optional() }) .optional() }); @@ -252,9 +252,9 @@ export class Config { ? "true" : "false"; process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags - ?.allow_raw_resources - ? "true" - : "false"; + ?.allow_raw_resources + ? "true" + : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; @@ -270,6 +270,10 @@ export class Config { parsedConfig.data.server.resource_access_token_param; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.data.server.resource_session_request_param; + process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags + ?.allow_base_domain_resources + ? "true" + : "false"; this.rawConfig = parsedConfig.data; } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index a5669a1d..01d6ee2b 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -34,7 +34,8 @@ const createResourceSchema = z siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().optional() + proxyPort: z.number().optional(), + isBaseDomain: z.boolean().optional() }) .refine( (data) => { @@ -55,7 +56,7 @@ const createResourceSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -75,7 +76,7 @@ const createResourceSchema = z return true; }, { - message: "Cannot update proxyPort" + message: "Proxy port cannot be set" } ) .refine( @@ -88,6 +89,19 @@ const createResourceSchema = z { message: "Port 80 and 443 are reserved for http and https resources" } + ) + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_base_domain_resources) { + if (data.isBaseDomain) { + return false; + } + } + return true; + }, + { + message: "Base domain resources are not allowed" + } ); export type CreateResourceResponse = Resource; @@ -108,7 +122,7 @@ export async function createResource( ); } - let { name, subdomain, protocol, proxyPort, http } = parsedBody.data; + let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data; // Validate request params const parsedParams = createResourceParamsSchema.safeParse(req.params); @@ -145,7 +159,13 @@ export async function createResource( ); } - const fullDomain = `${subdomain}.${org[0].domain}`; + let fullDomain = ""; + if (isBaseDomain) { + fullDomain = org[0].domain; + } else { + fullDomain = `${subdomain}.${org[0].domain}`; + } + // if http is false check to see if there is already a resource with the same port and protocol if (!http) { const existingResource = await db @@ -195,7 +215,8 @@ export async function createResource( http, protocol, proxyPort, - ssl: true + ssl: true, + isBaseDomain }) .returning(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6910bd76..60b75f8f 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -28,7 +28,8 @@ const updateResourceBodySchema = z sso: z.boolean().optional(), blockAccess: z.boolean().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), - emailWhitelistEnabled: z.boolean().optional() + emailWhitelistEnabled: z.boolean().optional(), + isBaseDomain: z.boolean().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -55,6 +56,19 @@ const updateResourceBodySchema = z { message: "Port 80 and 443 are reserved for http and https resources" } + ) + .refine( + (data) => { + if (!config.getRawConfig().flags?.allow_base_domain_resources) { + if (data.isBaseDomain) { + return false; + } + } + return true; + }, + { + message: "Base domain resources are not allowed" + } ); export async function updateResource( @@ -104,6 +118,29 @@ export async function updateResource( ); } + if (updateData.subdomain) { + if (!resource.http) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot update subdomain for non-http resource" + ) + ); + } + + const valid = subdomainSchema.safeParse( + updateData.subdomain + ).success; + if (!valid) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subdomain provided" + ) + ); + } + } + if (updateData.proxyPort) { const proxyPort = updateData.proxyPort; const existingResource = await db @@ -138,15 +175,32 @@ export async function updateResource( ); } - const fullDomain = updateData.subdomain - ? `${updateData.subdomain}.${org.domain}` - : undefined; + let fullDomain = ""; + if (updateData.isBaseDomain) { + fullDomain = org.domain; + } else { + fullDomain = `${updateData.subdomain}.${org.domain}`; + } const updatePayload = { ...updateData, ...(fullDomain && { fullDomain }) }; + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if (existingDomain && existingDomain.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + const updatedResource = await db .update(resources) .set(updatePayload) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 7c12cdb3..98702aae 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -25,6 +25,7 @@ export async function traefikConfigProvider( http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, + isBaseDomain: resources.isBaseDomain, // Site fields site: { siteId: sites.siteId, @@ -110,11 +111,11 @@ export async function traefikConfigProvider( const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; - const fullDomain = `${resource.subdomain}.${org.domain}`; + const fullDomain = `${resource.fullDomain}`; if (resource.http) { // HTTP configuration remains the same - if (!resource.subdomain) { + if (!resource.subdomain && !resource.isBaseDomain) { continue; } @@ -148,6 +149,8 @@ export async function traefikConfigProvider( : {}) }; + logger.debug(config.getRawConfig().traefik.prefer_wildcard_cert) + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 5a5e6711..8f3af8d6 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -23,7 +23,12 @@ export async function copyInConfig() { const allResources = await trx.select().from(resources); for (const resource of allResources) { - const fullDomain = `${resource.subdomain}.${domain}`; + let fullDomain = ""; + if (resource.isBaseDomain) { + fullDomain = domain; + } else { + fullDomain = `${resource.subdomain}.${domain}`; + } await trx .update(resources) .set({ fullDomain }) diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index e0e25f15..5581fc24 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -3,8 +3,9 @@ import db, { exists } from "@server/db"; import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; -import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; +import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; +import fs from "fs"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; @@ -12,6 +13,7 @@ import m4 from "./scripts/1.0.0-beta5"; import m5 from "./scripts/1.0.0-beta6"; import m6 from "./scripts/1.0.0-beta9"; import m7 from "./scripts/1.0.0-beta10"; +import m8 from "./scripts/1.0.0-beta12"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -24,12 +26,41 @@ const migrations = [ { version: "1.0.0-beta.5", run: m4 }, { version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.9", run: m6 }, - { version: "1.0.0-beta.10", run: m7 } + { version: "1.0.0-beta.10", run: m7 }, + { version: "1.0.0-beta.12", run: m8 } // Add new migrations here as they are created ] as const; -// Run the migrations -await runMigrations(); +await run(); + +async function run() { + // backup the database + backupDb(); + + // run the migrations + await runMigrations(); +} + +function backupDb() { + // make dir config/db/backups + const appPath = APP_PATH; + const dbDir = path.join(appPath, "db"); + + const backupsDir = path.join(dbDir, "backups"); + + // check if the backups directory exists and create it if it doesn't + if (!fs.existsSync(backupsDir)) { + fs.mkdirSync(backupsDir, { recursive: true }); + } + + // copy the db.sqlite file to backups + // add the date to the filename + const date = new Date(); + const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; + const dbPath = path.join(dbDir, "db.sqlite"); + const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); + fs.copyFileSync(dbPath, backupPath); +} export async function runMigrations() { try { @@ -105,7 +136,10 @@ async function executeScripts() { `Successfully completed migration ${migration.version}` ); } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if ( + e instanceof SqliteError && + e.code === "SQLITE_CONSTRAINT_UNIQUE" + ) { console.error("Migration has already run! Skipping..."); continue; } diff --git a/server/setup/scripts/1.0.0-beta12.ts b/server/setup/scripts/1.0.0-beta12.ts new file mode 100644 index 00000000..0632b5e1 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta12.ts @@ -0,0 +1,62 @@ +import db from "@server/db"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.12..."); + + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + if (!rawConfig.flags) { + rawConfig.flags = {}; + } + + rawConfig.flags.allow_base_domain_resources = true; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Added new config option: allow_base_domain_resources`); + } catch (e) { + console.log( + `Unable to add new config option: allow_base_domain_resources. This is not critical.` + ); + console.error(e); + } + + try { + db.transaction((trx) => { + trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`); + }); + + console.log(`Added new column: isBaseDomain`); + } catch (e) { + console.log("Unable to add new column: isBaseDomain"); + throw e; + } + + console.log("Done."); +} diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 4add3044..6e33ec79 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -63,6 +63,8 @@ import { subdomainSchema } from "@server/schemas/subdomainSchema"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; const createResourceFormSchema = z .object({ @@ -71,7 +73,8 @@ const createResourceFormSchema = z siteId: z.number(), http: z.boolean(), protocol: z.string(), - proxyPort: z.number().optional() + proxyPort: z.number().optional(), + isBaseDomain: z.boolean().optional() }) .refine( (data) => { @@ -92,7 +95,7 @@ const createResourceFormSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -131,12 +134,15 @@ export default function CreateResourceForm({ const [domainSuffix, setDomainSuffix] = useState(org.org.domain); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); + const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( + "subdomain" + ); const form = useForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", - name: "My Resource", + name: "", http: true, protocol: "tcp" } @@ -180,7 +186,8 @@ export default function CreateResourceForm({ http: data.http, protocol: data.protocol, proxyPort: data.http ? undefined : data.proxyPort, - siteId: data.siteId + siteId: data.siteId, + isBaseDomain: data.isBaseDomain } ) .catch((e) => { @@ -246,7 +253,7 @@ export default function CreateResourceForm({ Name @@ -291,33 +298,89 @@ export default function CreateResourceForm({ /> )} + {form.watch("http") && + env.flags.allowBaseDomainResources && ( +
+ { + setDomainType( + val as any + ); + form.setValue( + "isBaseDomain", + val === "basedomain" + ); + }} + > +
+ + +
+
+ + +
+
+
+ )} + {form.watch("http") && ( ( - - Subdomain - - - - form.setValue( - "subdomain", + {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} + {domainType === + "subdomain" ? ( + + - + ) => + form.setValue( + "subdomain", + value + ) + } + /> + + ) : ( + + + + )} This is the fully qualified domain name @@ -471,9 +534,7 @@ export default function CreateResourceForm({ site ) => ( - {!showSnippets && } + {!showSnippets && ( + + )} - {showSnippets && } + {showSnippets && ( + + )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index c7f51622..38f3c84d 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -2,11 +2,7 @@ import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - InfoIcon, - ShieldCheck, - ShieldOff -} from "lucide-react"; +import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { Separator } from "@app/components/ui/separator"; @@ -26,9 +22,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { const { org } = useOrgContext(); const { resource, authInfo } = useResourceContext(); - const fullUrl = `${resource.ssl ? "https" : "http"}://${ - resource.subdomain - }.${org.org.domain}`; + let fullUrl = `${resource.ssl ? "https" : "http"}://`; + if (resource.isBaseDomain) { + fullUrl = fullUrl + org.org.domain; + } else { + fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`; + } return ( @@ -82,7 +81,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { Protocol - {resource.protocol.toUpperCase()} + + {resource.protocol.toUpperCase()} + diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index cf13ea99..7cc21914 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -132,9 +132,8 @@ export default function ReverseProxyTargets(props: { defaultValues: { ip: "", method: resource.http ? "http" : null, - port: "" // protocol: "TCP", - } + } as z.infer }); useEffect(() => { diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a6987e92..848115f8 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -51,13 +51,17 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { subdomainSchema } from "@server/schemas/subdomainSchema"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { pullEnv } from "@app/lib/pullEnv"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Label } from "@app/components/ui/label"; const GeneralFormSchema = z .object({ subdomain: z.string().optional(), name: z.string().min(1).max(255), proxyPort: z.number().optional(), - http: z.boolean() + http: z.boolean(), + isBaseDomain: z.boolean().optional() }) .refine( (data) => { @@ -78,7 +82,7 @@ const GeneralFormSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -103,9 +107,11 @@ export default function GeneralForm() { const { org } = useOrgContext(); const router = useRouter(); + const { env } = useEnvContext(); + const orgId = params.orgId; - const api = createApiClient(useEnvContext()); + const api = createApiClient({ env }); const [sites, setSites] = useState([]); const [saveLoading, setSaveLoading] = useState(false); @@ -113,13 +119,18 @@ export default function GeneralForm() { const [transferLoading, setTransferLoading] = useState(false); const [open, setOpen] = useState(false); + const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( + resource.isBaseDomain ? "basedomain" : "subdomain" + ); + const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: resource.name, subdomain: resource.subdomain ? resource.subdomain : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined, - http: resource.http + http: resource.http, + isBaseDomain: resource.isBaseDomain ? true : false }, mode: "onChange" }); @@ -148,7 +159,8 @@ export default function GeneralForm() { .post(`resource/${resource?.resourceId}`, { name: data.name, subdomain: data.subdomain, - proxyPort: data.proxyPort + proxyPort: data.proxyPort, + isBaseDomain: data.isBaseDomain }) .catch((e) => { toast({ @@ -170,7 +182,8 @@ export default function GeneralForm() { updateResource({ name: data.name, subdomain: data.subdomain, - proxyPort: data.proxyPort + proxyPort: data.proxyPort, + isBaseDomain: data.isBaseDomain }); } setSaveLoading(false); @@ -242,40 +255,103 @@ export default function GeneralForm() { )} /> - {resource.http ? ( - ( - - Subdomain - - - form.setValue( - "subdomain", - value - ) - } - /> - - - This is the subdomain that - will be used to access the - resource. - - - + {resource.http && ( + <> + {env.flags.allowBaseDomainResources && ( +
+ { + setDomainType( + val as any + ); + form.setValue( + "isBaseDomain", + val === "basedomain" + ); + }} + > +
+ + +
+
+ + +
+
+
)} - /> - ) : ( + + ( + + {!env.flags + .allowBaseDomainResources && ( + + Subdomain + + )} + + {domainType === + "subdomain" ? ( + + + form.setValue( + "subdomain", + value + ) + } + /> + + ) : ( + + + + )} + + This is the subdomain + that will be used to + access the resource. + + + + )} + /> + + )} + + {!resource.http && ( , - React.ComponentPropsWithoutRef + React.ElementRef, + React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - - - - - -)) -Checkbox.displayName = CheckboxPrimitive.Root.displayName + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; -export { Checkbox } +interface CheckboxWithLabelProps + extends React.ComponentPropsWithoutRef { + label: string; +} + +const CheckboxWithLabel = React.forwardRef< + React.ElementRef, + CheckboxWithLabelProps +>(({ className, label, id, ...props }, ref) => { + return ( +
+ + +
+ ); +}); +CheckboxWithLabel.displayName = "CheckboxWithLabel"; + +export { Checkbox, CheckboxWithLabel }; diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 368df440..e189db5f 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -6,8 +6,10 @@ export function pullEnv(): Env { nextPort: process.env.NEXT_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string, - resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string, - resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string + resourceAccessTokenParam: process.env + .RESOURCE_ACCESS_TOKEN_PARAM as string, + resourceSessionRequestParam: process.env + .RESOURCE_SESSION_REQUEST_PARAM as string }, app: { environment: process.env.ENVIRONMENT as string, @@ -29,6 +31,10 @@ export function pullEnv(): Env { : false, allowRawResources: process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false, + allowBaseDomainResources: + process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES === "true" + ? true + : false } }; } diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 14efd1be..7080d460 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -18,5 +18,6 @@ export type Env = { disableUserCreateOrg: boolean; emailVerificationRequired: boolean; allowRawResources: boolean; + allowBaseDomainResources: boolean; } }; From 7cf798851ca07750be86bd295a1f9edc8ddbbce5 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 3 Feb 2025 22:47:47 -0500 Subject: [PATCH 14/19] fix sorting auth column if no auth closes #149 --- .../settings/resources/ResourcesTable.tsx | 52 +++++++++---------- src/app/[orgId]/settings/resources/page.tsx | 13 +++-- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 463c8461..fee92999 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -38,7 +38,7 @@ export type ResourceRow = { domain: string; site: string; siteId: string; - hasAuth: boolean; + authState: string; http: boolean; protocol: string; proxyPort: number | null; @@ -165,9 +165,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { header: "Protocol", cell: ({ row }) => { const resourceRow = row.original; - return ( - {resourceRow.protocol.toUpperCase()} - ); + return {resourceRow.protocol.toUpperCase()}; } }, { @@ -177,17 +175,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
- {!resourceRow.http ? ( - - ) : ( - - )} + {!resourceRow.http ? ( + + ) : ( + + )}
); } }, { - accessorKey: "hasAuth", + accessorKey: "authState", header: ({ column }) => { return (