diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c211e91b..65b01b26 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -35,13 +35,8 @@ jobs: - name: Update version in package.json run: | TAG=${{ env.TAG }} - if [ -f package.json ]; then - jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json - echo "Updated package.json with version $TAG" - else - echo "package.json not found" - fi - cat package.json + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/ - name: Pull latest Gerbil version id: get-gerbil-tag 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. diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b6184c67..bc5ad10c 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -37,7 +37,7 @@ services: - 80:80 # Port for traefik because of the network_mode traefik: - image: traefik:v3.1 + image: traefik:v3.3.3 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service @@ -49,3 +49,8 @@ services: volumes: - ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + +networks: + default: + driver: bridge + name: pangolin \ No newline at end of file diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index ab6528d0..ea673eb0 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -36,7 +36,7 @@ services: {{end}} traefik: - image: traefik:v3.1 + image: traefik:v3.3.3 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} @@ -55,3 +55,8 @@ services: volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + +networks: + default: + driver: bridge + name: pangolin \ No newline at end of file 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 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 | 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/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 14e96af1..7c5ad227 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -6,10 +6,10 @@ import { fromError } from "zod-validation-error"; import { __DIRNAME, APP_PATH, + APP_VERSION, configFilePath1, configFilePath2 } from "@server/lib/consts"; -import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; @@ -151,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() }); @@ -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 = @@ -255,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"; @@ -273,6 +270,11 @@ 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"; + process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; this.rawConfig = parsedConfig.data; } 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/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/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/createResource.ts b/server/routers/resource/createResource.ts index e687cc02..9f7fa1fb 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({ @@ -33,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) => { @@ -54,7 +56,7 @@ const createResourceSchema = z ) .refine( (data) => { - if (data.http) { + if (data.http && !data.isBaseDomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; @@ -63,6 +65,43 @@ 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: "Proxy port cannot be set" + } + ) + // .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" + // } + // ) + .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; @@ -83,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); @@ -120,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 @@ -142,15 +187,6 @@ export async function createResource( ); } } else { - if (proxyPort === 443 || proxyPort === 80) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Port 80 and 443 are reserved for https resources" - ) - ); - } - // make sure the full domain is unique const existingResource = await db .select() @@ -179,7 +215,8 @@ export async function createResource( http, protocol, proxyPort, - ssl: true + ssl: true, + isBaseDomain }) .returning(); 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/index.ts b/server/routers/resource/index.ts index ca06dfc3..187d23fe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,4 +16,5 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./transferResource"; export * from "./getExchangeToken"; diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts new file mode 100644 index 00000000..69c9a2a6 --- /dev/null +++ b/server/routers/resource/transferResource.ts @@ -0,0 +1,192 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +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({ + 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 [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 this site` + ) + ); + } + + 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 }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + if (!updatedResource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + 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, + 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/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 9a80fb7d..94958978 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -2,13 +2,14 @@ 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"; 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({ @@ -27,12 +28,48 @@ 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, { 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" + // } + // ) + .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( req: Request, @@ -63,13 +100,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 +118,55 @@ export async function updateResource( ); } - if (!resource[0].orgs?.domain) { + 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 + .select() + .from(resources) + .where( + and( + eq(resources.protocol, resource.protocol), + eq(resources.proxyPort, proxyPort!) + ) + ); + + 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, @@ -87,15 +175,32 @@ export async function updateResource( ); } - const fullDomain = updateData.subdomain - ? `${updateData.subdomain}.${resource[0].orgs.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) @@ -111,10 +216,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/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/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 b06f176c..5581fc24 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -3,9 +3,9 @@ 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_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"; @@ -13,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 @@ -25,19 +26,45 @@ 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 { - const appVersion = loadAppVersion(); - if (!appVersion) { - throw new Error("APP_VERSION is not set in the environment"); - } + const appVersion = APP_VERSION; if (exists) { await executeScripts(); @@ -109,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 abe5608c..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; @@ -129,26 +132,36 @@ 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 [domainType, setDomainType] = useState<"subdomain" | "basedomain">( + "subdomain" + ); const form = useForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { subdomain: "", - name: "My Resource", + name: "", http: true, protocol: "tcp" } }); + 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/` @@ -173,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) => { @@ -239,7 +253,7 @@ export default function CreateResourceForm({ Name @@ -284,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 @@ -464,9 +534,7 @@ export default function CreateResourceForm({ site ) => ( - {!showSnippets && } - - {showSnippets && } + {!showSnippets && ( + + )} + + {showSnippets && ( + + )} 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 ( + + + + + + + No sites found. + + + {sites.map( + (site) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + Select the new site to transfer + this resource to. + + +
+ )} + /> + + + + + + + + + ); } diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index f9b5558b..b08ffd61 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -56,11 +56,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) { protocol: resource.protocol, proxyPort: resource.proxyPort, http: resource.http, - hasAuth: - resource.sso || - resource.pincodeId !== null || - resource.pincodeId !== null || - resource.whitelist + authState: !resource.http + ? "none" + : resource.sso || + resource.pincodeId !== null || + resource.pincodeId !== null || + resource.whitelist + ? "protected" + : "not_protected" }; }); diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index 0c36c57e..ccdf25fb 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -320,7 +320,7 @@ export default function CreateShareLinkForm({ ) => ( diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 494269c4..9edd2802 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -1,30 +1,53 @@ -"use client" +"use client"; -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { Check } from "lucide-react" +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; -import { cn } from "@app/lib/cn" +import { cn } from "@app/lib/cn"; const Checkbox = React.forwardRef< - React.ElementRef, - 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..7d46fd4a 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -6,12 +6,15 @@ 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, - version: process.env.APP_VERSION as string + version: process.env.APP_VERSION as string, + dashboardUrl: process.env.DASHBOARD_URL as string, }, email: { emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false @@ -29,6 +32,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..8e25b63d 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -2,6 +2,7 @@ export type Env = { app: { environment: string; version: string; + dashboardUrl: string; }, server: { externalPort: string; @@ -18,5 +19,6 @@ export type Env = { disableUserCreateOrg: boolean; emailVerificationRequired: boolean; allowRawResources: boolean; + allowBaseDomainResources: boolean; } };