Просмотр исходного кода

Merge pull request #159 from fosrl/dev

1.0.0-beta12
Milo Schwartz 5 месяцев назад
Родитель
Сommit
9e5d5e8990
38 измененных файлов с 1364 добавлено и 306 удалено
  1. 2 7
      .github/workflows/cicd.yml
  2. 5 1
      README.md
  3. 6 1
      docker-compose.example.yml
  4. 6 1
      install/fs/docker-compose.yml
  5. 267 0
      internationalization/de.md
  6. 20 0
      internationalization/pl.md
  7. 1 1
      package.json
  8. 2 1
      server/db/schema.ts
  9. 12 10
      server/lib/config.ts
  10. 3 1
      server/lib/consts.ts
  11. 0 16
      server/lib/loadAppVersion.ts
  12. 7 0
      server/routers/external.ts
  13. 2 14
      server/routers/gerbil/getConfig.ts
  14. 4 4
      server/routers/newt/targets.ts
  15. 51 14
      server/routers/resource/createResource.ts
  16. 2 17
      server/routers/resource/deleteResource.ts
  17. 1 0
      server/routers/resource/index.ts
  18. 192 0
      server/routers/resource/transferResource.ts
  19. 114 13
      server/routers/resource/updateResource.ts
  20. 1 1
      server/routers/target/createTarget.ts
  21. 2 17
      server/routers/target/deleteTarget.ts
  22. 18 0
      server/routers/target/helpers.ts
  23. 1 1
      server/routers/target/updateTarget.ts
  24. 5 2
      server/routers/traefik/getTraefikConfig.ts
  25. 6 1
      server/setup/copyInConfig.ts
  26. 40 10
      server/setup/migrations.ts
  27. 62 0
      server/setup/scripts/1.0.0-beta12.ts
  28. 117 45
      src/app/[orgId]/settings/resources/CreateResourceForm.tsx
  29. 26 26
      src/app/[orgId]/settings/resources/ResourcesTable.tsx
  30. 10 9
      src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx
  31. 1 2
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  32. 309 52
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  33. 8 5
      src/app/[orgId]/settings/resources/page.tsx
  34. 1 1
      src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
  35. 2 7
      src/app/[orgId]/settings/sites/CreateSiteForm.tsx
  36. 46 23
      src/components/ui/checkbox.tsx
  37. 10 3
      src/lib/pullEnv.ts
  38. 2 0
      src/lib/types/env.ts

+ 2 - 7
.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

+ 5 - 1
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.

+ 6 - 1
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

+ 6 - 1
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

+ 267 - 0
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<br>                                          | 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<br>                    | Kompatibel mit allen WireGuard-Clients<br>                   |       |
+| Manual configuration required                                | Manuelle Konfiguration erforderlich<br>                      |       |
+##### 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                                   |              |

+ 20 - 0
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       |

+ 1 - 1
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",

+ 2 - 1
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", {

+ 12 - 10
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;
     }

+ 3 - 1
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);

+ 0 - 16
server/lib/loadAppVersion.ts

@@ -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;
-        }
-    }
-}

+ 7 - 0
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,

+ 2 - 14
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)
             };
         }));
 

+ 4 - 4
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<void> {
+) {
     //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<void> {
+) {
     //create a list of udp and tcp targets
     const payloadTargets = targets.map((target) => {
         return `${target.internalPort ? target.internalPort + ":" : ""}${

+ 51 - 14
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();
 

+ 2 - 17
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

+ 1 - 0
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";

+ 192 - 0
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<any> {
+    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")
+        );
+    }
+}

+ 114 - 13
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,

+ 1 - 1
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 =

+ 2 - 17
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

+ 18 - 0
server/routers/target/ports.ts → 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();
+}

+ 1 - 1
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 =

+ 5 - 2
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 || [];
 

+ 6 - 1
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 })

+ 40 - 10
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;
                 }

+ 62 - 0
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.");
+}

+ 117 - 45
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<ListSitesResponse["sites"]>([]);
     const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
-
     const [showSnippets, setShowSnippets] = useState(false);
-
     const [resourceId, setResourceId] = useState<number | null>(null);
+    const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
+        "subdomain"
+    );
 
     const form = useForm<CreateResourceFormValues>({
         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<AxiosResponse<ListSitesResponse>>(
                 `/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({
                                                 <FormLabel>Name</FormLabel>
                                                 <FormControl>
                                                     <Input
-                                                        placeholder="Your name"
+                                                        placeholder="Resource name"
                                                         {...field}
                                                     />
                                                 </FormControl>
@@ -284,33 +298,89 @@ export default function CreateResourceForm({
                                         />
                                     )}
 
+                                    {form.watch("http") &&
+                                        env.flags.allowBaseDomainResources && (
+                                            <div>
+                                                <RadioGroup
+                                                    className="flex space-x-4"
+                                                    defaultValue={domainType}
+                                                    onValueChange={(val) => {
+                                                        setDomainType(
+                                                            val as any
+                                                        );
+                                                        form.setValue(
+                                                            "isBaseDomain",
+                                                            val === "basedomain"
+                                                        );
+                                                    }}
+                                                >
+                                                    <div className="flex items-center space-x-2">
+                                                        <RadioGroupItem
+                                                            value="subdomain"
+                                                            id="r1"
+                                                        />
+                                                        <Label htmlFor="r1">
+                                                            Subdomain
+                                                        </Label>
+                                                    </div>
+                                                    <div className="flex items-center space-x-2">
+                                                        <RadioGroupItem
+                                                            value="basedomain"
+                                                            id="r2"
+                                                        />
+                                                        <Label htmlFor="r2">
+                                                            Base Domain
+                                                        </Label>
+                                                    </div>
+                                                </RadioGroup>
+                                            </div>
+                                        )}
+
                                     {form.watch("http") && (
                                         <FormField
                                             control={form.control}
                                             name="subdomain"
                                             render={({ field }) => (
                                                 <FormItem>
-                                                    <FormLabel>
-                                                        Subdomain
-                                                    </FormLabel>
-                                                    <FormControl>
-                                                        <CustomDomainInput
-                                                            value={
-                                                                field.value ??
-                                                                ""
-                                                            }
-                                                            domainSuffix={
-                                                                domainSuffix
-                                                            }
-                                                            placeholder="Enter subdomain"
-                                                            onChange={(value) =>
-                                                                form.setValue(
-                                                                    "subdomain",
+                                                    {!env.flags
+                                                        .allowBaseDomainResources && (
+                                                        <FormLabel>
+                                                            Subdomain
+                                                        </FormLabel>
+                                                    )}
+                                                    {domainType ===
+                                                    "subdomain" ? (
+                                                        <FormControl>
+                                                            <CustomDomainInput
+                                                                value={
+                                                                    field.value ??
+                                                                    ""
+                                                                }
+                                                                domainSuffix={
+                                                                    domainSuffix
+                                                                }
+                                                                placeholder="Subdomain"
+                                                                onChange={(
                                                                     value
-                                                                )
-                                                            }
-                                                        />
-                                                    </FormControl>
+                                                                ) =>
+                                                                    form.setValue(
+                                                                        "subdomain",
+                                                                        value
+                                                                    )
+                                                                }
+                                                            />
+                                                        </FormControl>
+                                                    ) : (
+                                                        <FormControl>
+                                                            <Input
+                                                                value={
+                                                                    domainSuffix
+                                                                }
+                                                                readOnly
+                                                                disabled
+                                                            />
+                                                        </FormControl>
+                                                    )}
                                                     <FormDescription>
                                                         This is the fully
                                                         qualified domain name
@@ -464,9 +534,7 @@ export default function CreateResourceForm({
                                                                             site
                                                                         ) => (
                                                                             <CommandItem
-                                                                                value={
-                                                                                    site.niceId
-                                                                                }
+                                                                                value={`${site.siteId}:${site.name}:${site.niceId}`}
                                                                                 key={
                                                                                     site.siteId
                                                                                 }
@@ -560,21 +628,25 @@ export default function CreateResourceForm({
                         )}
                     </CredenzaBody>
                     <CredenzaFooter>
-                        {!showSnippets && <Button
-                            type="submit"
-                            form="create-resource-form"
-                            loading={loading}
-                            disabled={loading}
-                        >
-                            Create Resource
-                        </Button>}
-
-                        {showSnippets && <Button
-                            loading={loading}
-                            onClick={() => goToResource()}
-                        >
-                            Go to Resource
-                        </Button>}
+                        {!showSnippets && (
+                            <Button
+                                type="submit"
+                                form="create-resource-form"
+                                loading={loading}
+                                disabled={loading}
+                            >
+                                Create Resource
+                            </Button>
+                        )}
+
+                        {showSnippets && (
+                            <Button
+                                loading={loading}
+                                onClick={() => goToResource()}
+                            >
+                                Go to Resource
+                            </Button>
+                        )}
 
                         <CredenzaClose asChild>
                             <Button variant="outline">Close</Button>

+ 26 - 26
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 (
-                    <span>{resourceRow.protocol.toUpperCase()}</span>
-                );
+                return <span>{resourceRow.protocol.toUpperCase()}</span>;
             }
         },
         {
@@ -177,17 +175,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                 const resourceRow = row.original;
                 return (
                     <div>
-                    {!resourceRow.http ? (
-                                            <CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
-                    ) : (
-                    <CopyToClipboard text={resourceRow.domain} isLink={true} />
-                    )}
+                        {!resourceRow.http ? (
+                            <CopyToClipboard
+                                text={resourceRow.proxyPort!.toString()}
+                                isLink={false}
+                            />
+                        ) : (
+                            <CopyToClipboard
+                                text={resourceRow.domain}
+                                isLink={true}
+                            />
+                        )}
                     </div>
                 );
             }
         },
         {
-            accessorKey: "hasAuth",
+            accessorKey: "authState",
             header: ({ column }) => {
                 return (
                     <Button
@@ -205,23 +209,19 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                 const resourceRow = row.original;
                 return (
                     <div>
-                        
-
-                        {!resourceRow.http ? (
+                        {resourceRow.authState === "protected" ? (
+                            <span className="text-green-500 flex items-center space-x-2">
+                                <ShieldCheck className="w-4 h-4" />
+                                <span>Protected</span>
+                            </span>
+                        ) : resourceRow.authState === "not_protected" ? (
+                            <span className="text-yellow-500 flex items-center space-x-2">
+                                <ShieldOff className="w-4 h-4" />
+                                <span>Not Protected</span>
+                            </span>
+                        ) : (
                             <span>--</span>
-                        ) : 
-                            resourceRow.hasAuth ? (
-                                <span className="text-green-500 flex items-center space-x-2">
-                                    <ShieldCheck className="w-4 h-4" />
-                                    <span>Protected</span>
-                                </span>
-                            ) : (
-                                <span className="text-yellow-500 flex items-center space-x-2">
-                                    <ShieldOff className="w-4 h-4" />
-                                    <span>Not Protected</span>
-                                </span>
-                            )
-                        }
+                        )}
                     </div>
                 );
             }

+ 10 - 9
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 (
         <Alert>
@@ -82,7 +81,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
                             <InfoSection>
                                 <InfoSectionTitle>Protocol</InfoSectionTitle>
                                 <InfoSectionContent>
-                                    <span>{resource.protocol.toUpperCase()}</span>
+                                    <span>
+                                        {resource.protocol.toUpperCase()}
+                                    </span>
                                 </InfoSectionContent>
                             </InfoSection>
                             <Separator orientation="vertical" />

+ 1 - 2
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: resource.http ? 80 : resource.proxyPort || 1234
             // protocol: "TCP",
-        }
+        } as z.infer<typeof addTargetSchema>
     });
 
     useEffect(() => {

+ 309 - 52
src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx

@@ -14,6 +14,19 @@ import {
     FormMessage
 } from "@/components/ui/form";
 import { Input } from "@/components/ui/input";
+import {
+    Command,
+    CommandEmpty,
+    CommandGroup,
+    CommandInput,
+    CommandItem
+} from "@/components/ui/command";
+import { cn } from "@app/lib/cn";
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger
+} from "@/components/ui/popover";
 import { useResourceContext } from "@app/hooks/useResourceContext";
 import { ListSitesResponse } from "@server/routers/site";
 import { useEffect, useState } from "react";
@@ -37,13 +50,18 @@ import CustomDomainInput from "../CustomDomainInput";
 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) => {
@@ -64,7 +82,7 @@ const GeneralFormSchema = z
     )
     .refine(
         (data) => {
-            if (data.http) {
+            if (data.http && !data.isBaseDomain) {
                 return subdomainSchema.safeParse(data.subdomain).success;
             }
             return true;
@@ -75,7 +93,12 @@ const GeneralFormSchema = z
         }
     );
 
+const TransferFormSchema = z.object({
+    siteId: z.number()
+});
+
 type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
+type TransferFormValues = z.infer<typeof TransferFormSchema>;
 
 export default function GeneralForm() {
     const params = useParams();
@@ -84,13 +107,21 @@ 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<ListSitesResponse["sites"]>([]);
     const [saveLoading, setSaveLoading] = useState(false);
     const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
+    const [transferLoading, setTransferLoading] = useState(false);
+    const [open, setOpen] = useState(false);
+
+    const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
+        resource.isBaseDomain ? "basedomain" : "subdomain"
+    );
 
     const form = useForm<GeneralFormValues>({
         resolver: zodResolver(GeneralFormSchema),
@@ -98,11 +129,19 @@ export default function GeneralForm() {
             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"
     });
 
+    const transferForm = useForm<TransferFormValues>({
+        resolver: zodResolver(TransferFormSchema),
+        defaultValues: {
+            siteId: resource.siteId ? Number(resource.siteId) : undefined
+        }
+    });
+
     useEffect(() => {
         const fetchSites = async () => {
             const res = await api.get<AxiosResponse<ListSitesResponse>>(
@@ -116,14 +155,13 @@ export default function GeneralForm() {
     async function onSubmit(data: GeneralFormValues) {
         setSaveLoading(true);
 
-        api.post<AxiosResponse<GetResourceAuthInfoResponse>>(
-            `resource/${resource?.resourceId}`,
-            {
+        const res = await api
+            .post(`resource/${resource?.resourceId}`, {
                 name: data.name,
-                subdomain: data.subdomain
-                // siteId: data.siteId,
-            }
-        )
+                subdomain: data.subdomain,
+                proxyPort: data.proxyPort,
+                isBaseDomain: data.isBaseDomain
+            })
             .catch((e) => {
                 toast({
                     variant: "destructive",
@@ -133,18 +171,50 @@ export default function GeneralForm() {
                         "An error occurred while updating the resource"
                     )
                 });
+            });
+
+        if (res && res.status === 200) {
+            toast({
+                title: "Resource updated",
+                description: "The resource has been updated successfully"
+            });
+
+            updateResource({
+                name: data.name,
+                subdomain: data.subdomain,
+                proxyPort: data.proxyPort,
+                isBaseDomain: data.isBaseDomain
+            });
+        }
+        setSaveLoading(false);
+    }
+
+    async function onTransfer(data: TransferFormValues) {
+        setTransferLoading(true);
+
+        const res = await api
+            .post(`resource/${resource?.resourceId}/transfer`, {
+                siteId: data.siteId
             })
-            .then(() => {
+            .catch((e) => {
                 toast({
-                    title: "Resource updated",
-                    description: "The resource has been updated successfully"
+                    variant: "destructive",
+                    title: "Failed to transfer resource",
+                    description: formatAxiosError(
+                        e,
+                        "An error occurred while transferring the resource"
+                    )
                 });
+            });
 
-                updateResource({ name: data.name, subdomain: data.subdomain });
-
-                router.refresh();
-            })
-            .finally(() => setSaveLoading(false));
+        if (res && res.status === 200) {
+            toast({
+                title: "Resource transferred",
+                description: "The resource has been transferred successfully"
+            });
+            router.refresh();
+        }
+        setTransferLoading(false);
     }
 
     return (
@@ -185,40 +255,103 @@ export default function GeneralForm() {
                                     )}
                                 />
 
-                                {resource.http ? (
-                                    <FormField
-                                        control={form.control}
-                                        name="subdomain"
-                                        render={({ field }) => (
-                                            <FormItem>
-                                                <FormLabel>Subdomain</FormLabel>
-                                                <FormControl>
-                                                    <CustomDomainInput
-                                                        value={
-                                                            field.value || ""
-                                                        }
-                                                        domainSuffix={
-                                                            domainSuffix
-                                                        }
-                                                        placeholder="Enter subdomain"
-                                                        onChange={(value) =>
-                                                            form.setValue(
-                                                                "subdomain",
-                                                                value
-                                                            )
-                                                        }
-                                                    />
-                                                </FormControl>
-                                                <FormDescription>
-                                                    This is the subdomain that
-                                                    will be used to access the
-                                                    resource.
-                                                </FormDescription>
-                                                <FormMessage />
-                                            </FormItem>
+                                {resource.http && (
+                                    <>
+                                        {env.flags.allowBaseDomainResources && (
+                                            <div>
+                                                <RadioGroup
+                                                    className="flex space-x-4"
+                                                    defaultValue={domainType}
+                                                    onValueChange={(val) => {
+                                                        setDomainType(
+                                                            val as any
+                                                        );
+                                                        form.setValue(
+                                                            "isBaseDomain",
+                                                            val === "basedomain"
+                                                        );
+                                                    }}
+                                                >
+                                                    <div className="flex items-center space-x-2">
+                                                        <RadioGroupItem
+                                                            value="subdomain"
+                                                            id="r1"
+                                                        />
+                                                        <Label htmlFor="r1">
+                                                            Subdomain
+                                                        </Label>
+                                                    </div>
+                                                    <div className="flex items-center space-x-2">
+                                                        <RadioGroupItem
+                                                            value="basedomain"
+                                                            id="r2"
+                                                        />
+                                                        <Label htmlFor="r2">
+                                                            Base Domain
+                                                        </Label>
+                                                    </div>
+                                                </RadioGroup>
+                                            </div>
                                         )}
-                                    />
-                                ) : (
+
+                                        <FormField
+                                            control={form.control}
+                                            name="subdomain"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    {!env.flags
+                                                        .allowBaseDomainResources && (
+                                                        <FormLabel>
+                                                            Subdomain
+                                                        </FormLabel>
+                                                    )}
+
+                                                    {domainType ===
+                                                    "subdomain" ? (
+                                                        <FormControl>
+                                                            <CustomDomainInput
+                                                                value={
+                                                                    field.value ||
+                                                                    ""
+                                                                }
+                                                                domainSuffix={
+                                                                    domainSuffix
+                                                                }
+                                                                placeholder="Enter subdomain"
+                                                                onChange={(
+                                                                    value
+                                                                ) =>
+                                                                    form.setValue(
+                                                                        "subdomain",
+                                                                        value
+                                                                    )
+                                                                }
+                                                            />
+                                                        </FormControl>
+                                                    ) : (
+                                                        <FormControl>
+                                                            <Input
+                                                                value={
+                                                                    domainSuffix
+                                                                }
+                                                                readOnly
+                                                                disabled
+                                                            />
+                                                        </FormControl>
+                                                    )}
+                                                    <FormDescription>
+                                                        This is the subdomain
+                                                        that will be used to
+                                                        access the resource.
+                                                    </FormDescription>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    </>
+                                )}
+
+                                {!resource.http && (
                                     <FormField
                                         control={form.control}
                                         name="proxyPort"
@@ -273,6 +406,130 @@ export default function GeneralForm() {
                     </Button>
                 </SettingsSectionFooter>
             </SettingsSection>
+
+            <SettingsSection>
+                <SettingsSectionHeader>
+                    <SettingsSectionTitle>
+                        Transfer Resource
+                    </SettingsSectionTitle>
+                    <SettingsSectionDescription>
+                        Transfer this resource to a different site
+                    </SettingsSectionDescription>
+                </SettingsSectionHeader>
+
+                <SettingsSectionBody>
+                    <SettingsSectionForm>
+                        <Form {...transferForm}>
+                            <form
+                                onSubmit={transferForm.handleSubmit(onTransfer)}
+                                className="space-y-4"
+                                id="transfer-form"
+                            >
+                                <FormField
+                                    control={transferForm.control}
+                                    name="siteId"
+                                    render={({ field }) => (
+                                        <FormItem className="flex flex-col">
+                                            <FormLabel>
+                                                Destination Site
+                                            </FormLabel>
+                                            <Popover
+                                                open={open}
+                                                onOpenChange={setOpen}
+                                            >
+                                                <PopoverTrigger asChild>
+                                                    <FormControl>
+                                                        <Button
+                                                            variant="outline"
+                                                            role="combobox"
+                                                            className={cn(
+                                                                "w-full justify-between",
+                                                                !field.value &&
+                                                                    "text-muted-foreground"
+                                                            )}
+                                                        >
+                                                            {field.value
+                                                                ? sites.find(
+                                                                      (site) =>
+                                                                          site.siteId ===
+                                                                          field.value
+                                                                  )?.name
+                                                                : "Select site"}
+                                                            <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+                                                        </Button>
+                                                    </FormControl>
+                                                </PopoverTrigger>
+                                                <PopoverContent className="w-full p-0">
+                                                    <Command>
+                                                        <CommandInput
+                                                            placeholder="Search sites..."
+                                                            className="h-9"
+                                                        />
+                                                        <CommandEmpty>
+                                                            No sites found.
+                                                        </CommandEmpty>
+                                                        <CommandGroup>
+                                                            {sites.map(
+                                                                (site) => (
+                                                                    <CommandItem
+                                                                        value={`${site.name}:${site.siteId}`}
+                                                                        key={
+                                                                            site.siteId
+                                                                        }
+                                                                        onSelect={() => {
+                                                                            transferForm.setValue(
+                                                                                "siteId",
+                                                                                site.siteId
+                                                                            );
+                                                                            setOpen(
+                                                                                false
+                                                                            );
+                                                                        }}
+                                                                    >
+                                                                        {
+                                                                            site.name
+                                                                        }
+                                                                        <CheckIcon
+                                                                            className={cn(
+                                                                                "ml-auto h-4 w-4",
+                                                                                site.siteId ===
+                                                                                    field.value
+                                                                                    ? "opacity-100"
+                                                                                    : "opacity-0"
+                                                                            )}
+                                                                        />
+                                                                    </CommandItem>
+                                                                )
+                                                            )}
+                                                        </CommandGroup>
+                                                    </Command>
+                                                </PopoverContent>
+                                            </Popover>
+                                            <FormDescription>
+                                                Select the new site to transfer
+                                                this resource to.
+                                            </FormDescription>
+                                            <FormMessage />
+                                        </FormItem>
+                                    )}
+                                />
+                            </form>
+                        </Form>
+                    </SettingsSectionForm>
+                </SettingsSectionBody>
+
+                <SettingsSectionFooter>
+                    <Button
+                        type="submit"
+                        loading={transferLoading}
+                        disabled={transferLoading}
+                        form="transfer-form"
+                        variant="destructive"
+                    >
+                        Transfer Resource
+                    </Button>
+                </SettingsSectionFooter>
+            </SettingsSection>
         </SettingsContainer>
     );
 }

+ 8 - 5
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"
         };
     });
 

+ 1 - 1
src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx

@@ -320,7 +320,7 @@ export default function CreateShareLinkForm({
                                                                             ) => (
                                                                                 <CommandItem
                                                                                     value={
-                                                                                        r.resourceId.toString()
+                                                                                        `${r.name}:${r.resourceId}`
                                                                                     }
                                                                                     key={
                                                                                         r.resourceId

+ 2 - 7
src/app/[orgId]/settings/sites/CreateSiteForm.tsx

@@ -75,6 +75,7 @@ export default function CreateSiteForm({
     const { toast } = useToast();
 
     const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
 
     const [isLoading, setIsLoading] = useState(false);
     const [isChecked, setIsChecked] = useState(false);
@@ -234,13 +235,7 @@ Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
 PersistentKeepalive = 5`
             : "";
 
-    // am I at http or https?
-    let proto = "https:";
-    // if (typeof window !== "undefined") {
-    //     proto = window.location.protocol;
-    // }
-
-    const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;
+    const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
 
     return (
         <div className="space-y-4">

+ 46 - 23
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<typeof CheckboxPrimitive.Root>,
-  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+    React.ElementRef<typeof CheckboxPrimitive.Root>,
+    React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
 >(({ className, ...props }, ref) => (
-  <CheckboxPrimitive.Root
-    ref={ref}
-    className={cn(
-      "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
-      className
-    )}
-    {...props}
-  >
-    <CheckboxPrimitive.Indicator
-      className={cn("flex items-center justify-center text-current")}
+    <CheckboxPrimitive.Root
+        ref={ref}
+        className={cn(
+            "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+            className
+        )}
+        {...props}
     >
-      <Check className="h-4 w-4" />
-    </CheckboxPrimitive.Indicator>
-  </CheckboxPrimitive.Root>
-))
-Checkbox.displayName = CheckboxPrimitive.Root.displayName
+        <CheckboxPrimitive.Indicator
+            className={cn("flex items-center justify-center text-current")}
+        >
+            <Check className="h-4 w-4" />
+        </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
 
-export { Checkbox }
+interface CheckboxWithLabelProps
+    extends React.ComponentPropsWithoutRef<typeof Checkbox> {
+    label: string;
+}
+
+const CheckboxWithLabel = React.forwardRef<
+    React.ElementRef<typeof Checkbox>,
+    CheckboxWithLabelProps
+>(({ className, label, id, ...props }, ref) => {
+    return (
+        <div className={cn("flex items-center space-x-2", className)}>
+            <Checkbox id={id} ref={ref} {...props} />
+            <label
+                htmlFor={id}
+                className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+            >
+                {label}
+            </label>
+        </div>
+    );
+});
+CheckboxWithLabel.displayName = "CheckboxWithLabel";
+
+export { Checkbox, CheckboxWithLabel };

+ 10 - 3
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
         }
     };
 }

+ 2 - 0
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;
     }
 };