Browse Source

Merge pull request #111 from fosrl/dev

major changes for 1.0.0-beta.9
Milo Schwartz 6 months ago
parent
commit
16b131970b
77 changed files with 2462 additions and 884 deletions
  1. 82 0
      .github/workflows/cicd.yml
  2. 2 0
      .gitignore
  3. 1 0
      README.md
  4. 14 13
      config/config.example.yml
  5. 0 1
      config/traefik/dynamic_config.example.yml
  6. 11 2
      config/traefik/traefik_config.example.yml
  7. 0 1
      install/Makefile
  8. 19 18
      install/fs/config.yml
  9. 0 1
      install/fs/traefik/dynamic_config.yml
  10. 11 2
      install/fs/traefik/traefik_config.yml
  11. 52 20
      install/main.go
  12. 2 1
      package.json
  13. 1 1
      server/auth/resourceOtp.ts
  14. 1 1
      server/auth/sendEmailVerificationCode.ts
  15. 31 11
      server/auth/sessions/app.ts
  16. 24 17
      server/auth/sessions/resource.ts
  17. 10 4
      server/db/schema.ts
  18. 9 14
      server/emails/index.ts
  19. 1 1
      server/emails/templates/ResourceOTPCode.tsx
  20. 2 1
      server/index.ts
  21. 41 13
      server/lib/config.ts
  22. 1 1
      server/middlewares/verifyAdmin.ts
  23. 7 0
      server/middlewares/verifyUser.ts
  24. 5 0
      server/routers/auth/disable2fa.ts
  25. 19 1
      server/routers/auth/login.ts
  26. 11 6
      server/routers/auth/logout.ts
  27. 10 6
      server/routers/auth/requestPasswordReset.ts
  28. 20 2
      server/routers/auth/resetPassword.ts
  29. 19 1
      server/routers/auth/signup.ts
  30. 5 0
      server/routers/auth/verifyEmail.ts
  31. 5 0
      server/routers/auth/verifyTotp.ts
  32. 188 0
      server/routers/badger/exchangeSession.ts
  33. 1 0
      server/routers/badger/index.ts
  34. 150 55
      server/routers/badger/verifySession.ts
  35. 15 3
      server/routers/internal.ts
  36. 17 4
      server/routers/newt/getToken.ts
  37. 67 31
      server/routers/newt/handleRegisterMessage.ts
  38. 36 65
      server/routers/newt/targets.ts
  39. 13 13
      server/routers/resource/authWithAccessToken.ts
  40. 13 10
      server/routers/resource/authWithPassword.ts
  41. 14 20
      server/routers/resource/authWithPincode.ts
  42. 59 21
      server/routers/resource/authWithWhitelist.ts
  43. 86 17
      server/routers/resource/createResource.ts
  44. 1 1
      server/routers/resource/deleteResource.ts
  45. 109 0
      server/routers/resource/getExchangeToken.ts
  46. 1 0
      server/routers/resource/index.ts
  47. 8 2
      server/routers/resource/listResources.ts
  48. 14 1
      server/routers/resource/setResourceWhitelist.ts
  49. 5 1
      server/routers/resource/updateResource.ts
  50. 3 8
      server/routers/target/createTarget.ts
  51. 2 4
      server/routers/target/deleteTarget.ts
  52. 0 1
      server/routers/target/listTargets.ts
  53. 3 5
      server/routers/target/updateTarget.ts
  54. 226 131
      server/routers/traefik/getTraefikConfig.ts
  55. 1 1
      server/routers/traefik/index.ts
  56. 5 2
      server/routers/user/inviteUser.ts
  57. 25 19
      server/setup/migrations.ts
  58. 291 0
      server/setup/scripts/1.0.0-beta9.ts
  59. 8 8
      src/app/[orgId]/settings/access/users/UsersTable.tsx
  60. 223 43
      src/app/[orgId]/settings/resources/CreateResourceForm.tsx
  61. 52 27
      src/app/[orgId]/settings/resources/ResourcesTable.tsx
  62. 59 35
      src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx
  63. 16 2
      src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx
  64. 98 80
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  65. 112 51
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  66. 7 4
      src/app/[orgId]/settings/resources/[resourceId]/layout.tsx
  67. 3 0
      src/app/[orgId]/settings/resources/page.tsx
  68. 4 2
      src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx
  69. 5 7
      src/app/[orgId]/settings/share-links/ShareLinksTable.tsx
  70. 7 7
      src/app/[orgId]/settings/sites/SitesTable.tsx
  71. 15 4
      src/app/auth/resource/[resourceId]/AccessToken.tsx
  72. 1 1
      src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx
  73. 16 21
      src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx
  74. 22 34
      src/app/auth/resource/[resourceId]/page.tsx
  75. 38 0
      src/components/ui/info-popup.tsx
  76. 5 3
      src/lib/pullEnv.ts
  77. 2 1
      src/lib/types/env.ts

+ 82 - 0
.github/workflows/cicd.yml

@@ -0,0 +1,82 @@
+name: CI/CD Pipeline
+
+on:
+    push:
+        tags:
+            - "*"
+
+jobs:
+    release:
+        name: Build and Release
+        runs-on: ubuntu-latest
+
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v3
+
+            - name: Set up Docker Buildx
+              uses: docker/setup-buildx-action@v2
+
+            - name: Log in to Docker Hub
+              uses: docker/login-action@v2
+              with:
+                  username: ${{ secrets.DOCKER_HUB_USERNAME }}
+                  password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+            - name: Extract tag name
+              id: get-tag
+              run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
+
+            - name: Install Go
+              uses: actions/setup-go@v4
+              with:
+                  go-version: 1.23.0
+
+            - 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
+
+            - name: Pull latest Gerbil version
+              id: get-gerbil-tag
+              run: |
+                  LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
+                  echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV
+
+            - name: Pull latest Badger version
+              id: get-badger-tag
+              run: |
+                  LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
+                  echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
+
+            - name: Update install/main.go
+              run: |
+                  PANGOLIN_VERSION=${{ env.TAG }}
+                  GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
+                  sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
+                  sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
+                  sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
+                  echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
+                  cat install/main.go
+
+            - name: Build installer
+              working-directory: install
+              run: |
+                  make release
+
+            - name: Upload artifacts from /install/bin
+              uses: actions/upload-artifact@v4
+              with:
+                  name: install-bin
+                  path: install/bin/
+
+            - name: Build and push Docker images
+              run: |
+                  TAG=${{ env.TAG }}
+                  make build-release tag=$TAG

+ 2 - 0
.gitignore

@@ -31,3 +31,5 @@ dist
 installer
 *.tar
 bin
+.secrets
+test_event.json

+ 1 - 0
README.md

@@ -32,6 +32,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
 -   Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
 -   Built-in support for any WireGuard client.
 -   Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
+-   Support for HTTP/HTTPS and **raw TCP/UDP services**.
 
 ### Identity & Access Management
 

+ 14 - 13
config/config.example.yml

@@ -1,27 +1,27 @@
 app:
-    dashboard_url: http://localhost:3002
-    base_domain: localhost
-    log_level: info
+    dashboard_url: "http://localhost:3002"
+    base_domain: "localhost"
+    log_level: "info"
     save_logs: false
 
 server:
     external_port: 3000
     internal_port: 3001
     next_port: 3002
-    internal_hostname: pangolin
+    internal_hostname: "pangolin"
     secure_cookies: true
-    session_cookie_name: p_session
-    resource_session_cookie_name: p_resource_session
-    resource_access_token_param: p_token
+    session_cookie_name: "p_session_token"
+    resource_access_token_param: "p_token"
+    resource_session_request_param: "p_session_request"
 
 traefik:
-    cert_resolver: letsencrypt
-    http_entrypoint: web
-    https_entrypoint: websecure
+    cert_resolver: "letsencrypt"
+    http_entrypoint: "web"
+    https_entrypoint: "websecure"
 
 gerbil:
     start_port: 51820
-    base_endpoint: localhost
+    base_endpoint: "localhost"
     block_size: 24
     site_block_size: 30
     subnet_group: 100.89.137.0/20
@@ -34,10 +34,11 @@ rate_limits:
 
 users:
     server_admin:
-        email: admin@example.com
-        password: Password123!
+        email: "admin@example.com"
+        password: "Password123!"
 
 flags:
     require_email_verification: false
     disable_signup_without_invite: true
     disable_user_create_org: true
+    allow_raw_resources: true

+ 0 - 1
config/traefik/dynamic_config.example.yml

@@ -3,7 +3,6 @@ http:
     redirect-to-https:
       redirectScheme:
         scheme: https
-        permanent: true
 
   routers:
     # HTTP to HTTPS redirect router

+ 11 - 2
config/traefik/traefik_config.example.yml

@@ -4,7 +4,13 @@ api:
 
 providers:
   http:
-    endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
+    pollInterval: "5s"
+  udp:
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
+    pollInterval: "5s"
+  tcp:
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
     pollInterval: "5s"
   file:
     filename: "/etc/traefik/dynamic_config.yml"
@@ -13,7 +19,7 @@ experimental:
   plugins:
     badger:
       moduleName: "github.com/fosrl/badger"
-      version: "v1.0.0-beta.2"
+      version: "v1.0.0-beta.3"
 
 log:
   level: "INFO"
@@ -33,6 +39,9 @@ entryPoints:
     address: ":80"
   websecure:
     address: ":443"
+    transport:
+      respondingTimeouts:
+        readTimeout: "30m"
     http:
       tls:
         certResolver: "letsencrypt"

+ 0 - 1
install/Makefile

@@ -1,4 +1,3 @@
-
 all: build
 
 build:

+ 19 - 18
install/fs/config.yml

@@ -1,18 +1,18 @@
 app:
-    dashboard_url: https://{{.DashboardDomain}}
-    base_domain: {{.BaseDomain}}
-    log_level: info
+    dashboard_url: "https://{{.DashboardDomain}}"
+    base_domain: "{{.BaseDomain}}"
+    log_level: "info"
     save_logs: false
 
 server:
     external_port: 3000
     internal_port: 3001
     next_port: 3002
-    internal_hostname: pangolin
+    internal_hostname: "pangolin"
     secure_cookies: true
-    session_cookie_name: p_session
-    resource_session_cookie_name: p_resource_session
-    resource_access_token_param: p_token
+    session_cookie_name: "p_session_token"
+    resource_access_token_param: "p_token"
+    resource_session_request_param: "p_session_request"
     cors:
         origins: ["https://{{.DashboardDomain}}"]
         methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
@@ -20,14 +20,14 @@ server:
         credentials: false
 
 traefik:
-    cert_resolver: letsencrypt
-    http_entrypoint: web
-    https_entrypoint: websecure
+    cert_resolver: "letsencrypt"
+    http_entrypoint: "web"
+    https_entrypoint: "websecure"
     prefer_wildcard_cert: false
 
 gerbil:
     start_port: 51820
-    base_endpoint: {{.DashboardDomain}}
+    base_endpoint: "{{.DashboardDomain}}"
     use_subdomain: false
     block_size: 24
     site_block_size: 30
@@ -39,18 +39,19 @@ rate_limits:
         max_requests: 100
 {{if .EnableEmail}}
 email:
-    smtp_host: {{.EmailSMTPHost}}
-    smtp_port: {{.EmailSMTPPort}}
-    smtp_user: {{.EmailSMTPUser}}
-    smtp_pass: {{.EmailSMTPPass}}
-    no_reply: {{.EmailNoReply}}
+    smtp_host: "{{.EmailSMTPHost}}"
+    smtp_port: "{{.EmailSMTPPort}}"
+    smtp_user: "{{.EmailSMTPUser}}"
+    smtp_pass: "{{.EmailSMTPPass}}"
+    no_reply: "{{.EmailNoReply}}"
 {{end}}
 users:
     server_admin:
-        email: {{.AdminUserEmail}}
-        password: {{.AdminUserPassword}}
+        email: "{{.AdminUserEmail}}"
+        password: "{{.AdminUserPassword}}"
 
 flags:
     require_email_verification: {{.EnableEmail}}
     disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
     disable_user_create_org: {{.DisableUserCreateOrg}}
+    allow_raw_resources: true

+ 0 - 1
install/fs/traefik/dynamic_config.yml

@@ -3,7 +3,6 @@ http:
     redirect-to-https:
       redirectScheme:
         scheme: https
-        permanent: true
 
   routers:
     # HTTP to HTTPS redirect router

+ 11 - 2
install/fs/traefik/traefik_config.yml

@@ -4,7 +4,13 @@ api:
 
 providers:
   http:
-    endpoint: "http://pangolin:3001/api/v1/traefik-config"
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/http"
+    pollInterval: "5s"
+  udp:
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/udp"
+    pollInterval: "5s"
+  tcp:
+    endpoint: "http://pangolin:3001/api/v1/traefik-config/tcp"
     pollInterval: "5s"
   file:
     filename: "/etc/traefik/dynamic_config.yml"
@@ -13,7 +19,7 @@ experimental:
   plugins:
     badger:
       moduleName: "github.com/fosrl/badger"
-      version: "v1.0.0-beta.2"
+      version: "{{.BadgerVersion}}"
 
 log:
   level: "INFO"
@@ -33,6 +39,9 @@ entryPoints:
     address: ":80"
   websecure:
     address: ":443"
+    transport:
+      respondingTimeouts:
+        readTimeout: "30m"
     http:
       tls:
         certResolver: "letsencrypt"

+ 52 - 20
install/main.go

@@ -17,9 +17,11 @@ import (
 	"golang.org/x/term"
 )
 
+// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
 func loadVersions(config *Config) {
-	config.PangolinVersion = "1.0.0-beta.8"
-	config.GerbilVersion = "1.0.0-beta.3"
+	config.PangolinVersion = "replaceme"
+	config.GerbilVersion = "replaceme"
+	config.BadgerVersion = "replaceme"
 }
 
 //go:embed fs/*
@@ -28,6 +30,7 @@ var configFiles embed.FS
 type Config struct {
 	PangolinVersion            string
 	GerbilVersion              string
+	BadgerVersion              string
 	BaseDomain                 string
 	DashboardDomain            string
 	LetsEncryptEmail           string
@@ -271,6 +274,11 @@ func createConfigFiles(config Config) error {
 		// Get the relative path by removing the "fs/" prefix
 		relPath := strings.TrimPrefix(path, "fs/")
 
+        // skip .DS_Store
+        if strings.Contains(relPath, ".DS_Store") {
+            return nil
+        }
+
 		// Create the full output path under "config/"
 		outPath := filepath.Join("config", relPath)
 
@@ -374,7 +382,7 @@ func installDocker() error {
 	switch {
 	case strings.Contains(osRelease, "ID=ubuntu"):
 		installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
-			apt-get update && 
+			apt-get update &&
 			apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
 			curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
 			echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@@ -383,7 +391,7 @@ func installDocker() error {
 		`, dockerArch))
 	case strings.Contains(osRelease, "ID=debian"):
 		installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
-			apt-get update && 
+			apt-get update &&
 			apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
 			curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
 			echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@@ -432,29 +440,53 @@ func isDockerInstalled() bool {
 	return true
 }
 
+func getCommandString(useNewStyle bool) string {
+	if useNewStyle {
+		return "'docker compose'"
+	}
+	return "'docker-compose'"
+}
+
 func pullAndStartContainers() error {
 	fmt.Println("Starting containers...")
 
-	// First try docker compose (new style)
-	cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err := cmd.Run()
+	// Check which docker compose command is available
+	var useNewStyle bool
+	checkCmd := exec.Command("docker", "compose", "version")
+	if err := checkCmd.Run(); err == nil {
+		useNewStyle = true
+	} else {
+		// Check if docker-compose (old style) is available
+		checkCmd = exec.Command("docker-compose", "version")
+		if err := checkCmd.Run(); err != nil {
+			return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
+		}
+	}
 
-	if err != nil {
-		fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
-		os.Exit(1)
+	// Helper function to execute docker compose commands
+	executeCommand := func(args ...string) error {
+		var cmd *exec.Cmd
+		if useNewStyle {
+			cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
+		} else {
+			cmd = exec.Command("docker-compose", args...)
+		}
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+		return cmd.Run()
 	}
 
-	cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	err = cmd.Run()
+	// Pull containers
+	fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle))
+	if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil {
+		return fmt.Errorf("failed to pull containers: %v", err)
+	}
 
-	if err != nil {
-		fmt.Println("Failed to start containers using docker-compose command")
-		os.Exit(1)
+	// Start containers
+	fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
+	if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
+		return fmt.Errorf("failed to start containers: %v", err)
 	}
 
-	return err
+	return nil
 }

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
     "name": "@fosrl/pangolin",
-    "version": "1.0.0-beta.8",
+    "version": "1.0.0-beta.9",
     "private": true,
     "type": "module",
     "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@@ -64,6 +64,7 @@
         "moment": "2.30.1",
         "next": "15.1.3",
         "next-themes": "0.4.4",
+        "node-cache": "5.1.2",
         "node-fetch": "3.3.2",
         "nodemailer": "6.9.16",
         "oslo": "1.2.1",

+ 1 - 1
server/auth/resourceOtp.ts

@@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
         }),
         {
             to: email,
-            from: config.getRawConfig().email?.no_reply,
+            from: config.getNoReplyEmail(),
             subject: `Your one-time code to access ${resourceName}`
         }
     );

+ 1 - 1
server/auth/sendEmailVerificationCode.ts

@@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
         }),
         {
             to: email,
-            from: config.getRawConfig().email?.no_reply,
+            from: config.getNoReplyEmail(),
             subject: "Verify your email address"
         }
     );

+ 31 - 11
server/auth/sessions/app.ts

@@ -3,7 +3,13 @@ import {
     encodeHexLowerCase
 } from "@oslojs/encoding";
 import { sha256 } from "@oslojs/crypto/sha2";
-import { Session, sessions, User, users } from "@server/db/schema";
+import {
+    resourceSessions,
+    Session,
+    sessions,
+    User,
+    users
+} from "@server/db/schema";
 import db from "@server/db";
 import { eq } from "drizzle-orm";
 import config from "@server/lib/config";
@@ -13,9 +19,14 @@ import logger from "@server/logger";
 
 export const SESSION_COOKIE_NAME =
     config.getRawConfig().server.session_cookie_name;
-export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
+export const SESSION_COOKIE_EXPIRES =
+    1000 *
+    60 *
+    60 *
+    config.getRawConfig().server.dashboard_session_length_hours;
 export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
-export const COOKIE_DOMAIN = "." + config.getBaseDomain();
+export const COOKIE_DOMAIN =
+    "." + new URL(config.getRawConfig().app.dashboard_url).hostname;
 
 export function generateSessionToken(): string {
     const bytes = new Uint8Array(20);
@@ -65,12 +76,21 @@ export async function validateSessionToken(
         session.expiresAt = new Date(
             Date.now() + SESSION_COOKIE_EXPIRES
         ).getTime();
-        await db
-            .update(sessions)
-            .set({
-                expiresAt: session.expiresAt
-            })
-            .where(eq(sessions.sessionId, session.sessionId));
+        await db.transaction(async (trx) => {
+            await trx
+                .update(sessions)
+                .set({
+                    expiresAt: session.expiresAt
+                })
+                .where(eq(sessions.sessionId, session.sessionId));
+
+            await trx
+                .update(resourceSessions)
+                .set({
+                    expiresAt: session.expiresAt
+                })
+                .where(eq(resourceSessions.userSessionId, session.sessionId));
+        });
     }
     return { session, user };
 }
@@ -90,9 +110,9 @@ export function serializeSessionCookie(
     if (isSecure) {
         logger.debug("Setting cookie for secure origin");
         if (SECURE_COOKIES) {
-            return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
+            return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
         } else {
-            return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
+            return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`;
         }
     } else {
         return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;

+ 24 - 17
server/auth/sessions/resource.ts

@@ -6,19 +6,20 @@ import { eq, and } from "drizzle-orm";
 import config from "@server/lib/config";
 
 export const SESSION_COOKIE_NAME =
-    config.getRawConfig().server.resource_session_cookie_name;
-export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
+    config.getRawConfig().server.session_cookie_name;
+export const SESSION_COOKIE_EXPIRES =
+    1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
 export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
-export const COOKIE_DOMAIN = "." + config.getBaseDomain();
 
 export async function createResourceSession(opts: {
     token: string;
     resourceId: number;
-    passwordId?: number;
-    pincodeId?: number;
-    whitelistId?: number;
-    accessTokenId?: string;
-    usedOtp?: boolean;
+    isRequestToken?: boolean;
+    passwordId?: number | null;
+    pincodeId?: number | null;
+    userSessionId?: string | null;
+    whitelistId?: number | null;
+    accessTokenId?: string | null;
     doNotExtend?: boolean;
     expiresAt?: number | null;
     sessionLength?: number | null;
@@ -27,7 +28,8 @@ export async function createResourceSession(opts: {
         !opts.passwordId &&
         !opts.pincodeId &&
         !opts.whitelistId &&
-        !opts.accessTokenId
+        !opts.accessTokenId &&
+        !opts.userSessionId
     ) {
         throw new Error("Auth method must be provided");
     }
@@ -47,7 +49,9 @@ export async function createResourceSession(opts: {
         pincodeId: opts.pincodeId || null,
         whitelistId: opts.whitelistId || null,
         doNotExtend: opts.doNotExtend || false,
-        accessTokenId: opts.accessTokenId || null
+        accessTokenId: opts.accessTokenId || null,
+        isRequestToken: opts.isRequestToken || false,
+        userSessionId: opts.userSessionId || null
     };
 
     await db.insert(resourceSessions).values(session);
@@ -162,22 +166,25 @@ export async function invalidateAllSessions(
 
 export function serializeResourceSessionCookie(
     cookieName: string,
-    token: string
+    domain: string,
+    token: string,
+    isHttp: boolean = false
 ): string {
-    if (SECURE_COOKIES) {
-        return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
+    if (SECURE_COOKIES && !isHttp) {
+        return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
     } else {
-        return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
+        return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
     }
 }
 
 export function createBlankResourceSessionTokenCookie(
-    cookieName: string
+    cookieName: string,
+    domain: string
 ): string {
     if (SECURE_COOKIES) {
-        return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
+        return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
     } else {
-        return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
+        return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
     }
 }
 

+ 10 - 4
server/db/schema.ts

@@ -41,13 +41,16 @@ export const resources = sqliteTable("resources", {
         })
         .notNull(),
     name: text("name").notNull(),
-    subdomain: text("subdomain").notNull(),
-    fullDomain: text("fullDomain").notNull().unique(),
+    subdomain: text("subdomain"),
+    fullDomain: text("fullDomain"),
     ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
     blockAccess: integer("blockAccess", { mode: "boolean" })
         .notNull()
         .default(false),
     sso: integer("sso", { mode: "boolean" }).notNull().default(true),
+    http: integer("http", { mode: "boolean" }).notNull().default(true),
+    protocol: text("protocol").notNull(),
+    proxyPort: integer("proxyPort"),
     emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
         .notNull()
         .default(false)
@@ -61,10 +64,9 @@ export const targets = sqliteTable("targets", {
         })
         .notNull(),
     ip: text("ip").notNull(),
-    method: text("method").notNull(),
+    method: text("method"),
     port: integer("port").notNull(),
     internalPort: integer("internalPort"),
-    protocol: text("protocol"),
     enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
 });
 
@@ -313,6 +315,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
     doNotExtend: integer("doNotExtend", { mode: "boolean" })
         .notNull()
         .default(false),
+    isRequestToken: integer("isRequestToken", { mode: "boolean" }),
+    userSessionId: text("userSessionId").references(() => sessions.sessionId, {
+        onDelete: "cascade"
+    }),
     passwordId: integer("passwordId").references(
         () => resourcePassword.passwordId,
         {

+ 9 - 14
server/emails/index.ts

@@ -6,26 +6,21 @@ import logger from "@server/logger";
 
 function createEmailClient() {
     const emailConfig = config.getRawConfig().email;
-if (
-    !emailConfig?.smtp_host ||
-    !emailConfig?.smtp_pass ||
-    !emailConfig?.smtp_port ||
-    !emailConfig?.smtp_user
-) {
-    logger.warn(
-        "Email SMTP configuration is missing. Emails will not be sent.",
-    );
-    return;
-}
+    if (!emailConfig) {
+        logger.warn(
+            "Email SMTP configuration is missing. Emails will not be sent."
+        );
+        return;
+    }
 
     return nodemailer.createTransport({
         host: emailConfig.smtp_host,
         port: emailConfig.smtp_port,
-        secure: false,
+        secure: emailConfig.smtp_secure || false,
         auth: {
             user: emailConfig.smtp_user,
-            pass: emailConfig.smtp_pass,
-        },
+            pass: emailConfig.smtp_pass
+        }
     });
 }
 

+ 1 - 1
server/emails/templates/ResourceOTPCode.tsx

@@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
                         <EmailLetterHead />
 
                         <EmailHeading>
-                            Your One-Time Password for {resourceName}
+                            Your One-Time Code for {resourceName}
                         </EmailHeading>
 
                         <EmailGreeting>Hi {email || "there"},</EmailGreeting>

+ 2 - 1
server/index.ts

@@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
 import { createApiServer } from "./apiServer";
 import { createNextServer } from "./nextServer";
 import { createInternalServer } from "./internalServer";
-import { User, UserOrg } from "./db/schema";
+import { Session, User, UserOrg } from "./db/schema";
 
 async function startServers() {
     await runSetupFunctions();
@@ -24,6 +24,7 @@ declare global {
     namespace Express {
         interface Request {
             user?: User;
+            session?: Session;
             userOrg?: UserOrg;
             userOrgRoleId?: number;
             userOrgId?: string;

+ 41 - 13
server/lib/config.ts

@@ -37,9 +37,11 @@ const configSchema = z.object({
         base_domain: hostnameSchema
             .optional()
             .transform(getEnvOrYaml("APP_BASEDOMAIN"))
-            .pipe(hostnameSchema),
+            .pipe(hostnameSchema)
+            .transform((url) => url.toLowerCase()),
         log_level: z.enum(["debug", "info", "warn", "error"]),
-        save_logs: z.boolean()
+        save_logs: z.boolean(),
+        log_failed_attempts: z.boolean().optional()
     }),
     server: z.object({
         external_port: portSchema
@@ -60,8 +62,20 @@ const configSchema = z.object({
         internal_hostname: z.string().transform((url) => url.toLowerCase()),
         secure_cookies: z.boolean(),
         session_cookie_name: z.string(),
-        resource_session_cookie_name: z.string(),
         resource_access_token_param: z.string(),
+        resource_session_request_param: z.string(),
+        dashboard_session_length_hours: z
+            .number()
+            .positive()
+            .gt(0)
+            .optional()
+            .default(720),
+        resource_session_length_hours: z
+            .number()
+            .positive()
+            .gt(0)
+            .optional()
+            .default(720),
         cors: z
             .object({
                 origins: z.array(z.string()).optional(),
@@ -76,7 +90,8 @@ const configSchema = z.object({
         http_entrypoint: z.string(),
         https_entrypoint: z.string().optional(),
         cert_resolver: z.string().optional(),
-        prefer_wildcard_cert: z.boolean().optional()
+        prefer_wildcard_cert: z.boolean().optional(),
+        additional_middlewares: z.array(z.string()).optional()
     }),
     gerbil: z.object({
         start_port: portSchema
@@ -109,11 +124,12 @@ const configSchema = z.object({
     }),
     email: z
         .object({
-            smtp_host: z.string(),
-            smtp_port: portSchema,
-            smtp_user: z.string(),
-            smtp_pass: z.string(),
-            no_reply: z.string().email()
+            smtp_host: z.string().optional(),
+            smtp_port: portSchema.optional(),
+            smtp_user: z.string().optional(),
+            smtp_pass: z.string().optional(),
+            smtp_secure: z.boolean().optional(),
+            no_reply: z.string().email().optional()
         })
         .optional(),
     users: z.object({
@@ -123,7 +139,8 @@ const configSchema = z.object({
                 .email()
                 .optional()
                 .transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
-                .pipe(z.string().email()),
+                .pipe(z.string().email())
+                .transform((v) => v.toLowerCase()),
             password: passwordSchema
                 .optional()
                 .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
@@ -134,7 +151,8 @@ const configSchema = z.object({
         .object({
             require_email_verification: z.boolean().optional(),
             disable_signup_without_invite: z.boolean().optional(),
-            disable_user_create_org: z.boolean().optional()
+            disable_user_create_org: z.boolean().optional(),
+            allow_raw_resources: z.boolean().optional()
         })
         .optional()
 });
@@ -237,10 +255,12 @@ export class Config {
             ?.require_email_verification
             ? "true"
             : "false";
+        process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
+        ?.allow_raw_resources
+        ? "true"
+        : "false";
         process.env.SESSION_COOKIE_NAME =
             parsedConfig.data.server.session_cookie_name;
-        process.env.RESOURCE_SESSION_COOKIE_NAME =
-            parsedConfig.data.server.resource_session_cookie_name;
         process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
         process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
             ?.disable_signup_without_invite
@@ -252,6 +272,8 @@ export class Config {
             : "false";
         process.env.RESOURCE_ACCESS_TOKEN_PARAM =
             parsedConfig.data.server.resource_access_token_param;
+        process.env.RESOURCE_SESSION_REQUEST_PARAM =
+            parsedConfig.data.server.resource_session_request_param;
 
         this.rawConfig = parsedConfig.data;
     }
@@ -264,6 +286,12 @@ export class Config {
         return this.rawConfig.app.base_domain;
     }
 
+    public getNoReplyEmail(): string | undefined {
+        return (
+            this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
+        );
+    }
+
     private createTraefikConfig() {
         try {
             // check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik

+ 1 - 1
server/middlewares/verifyAdmin.ts

@@ -13,7 +13,7 @@ export async function verifyAdmin(
     const userId = req.user?.userId;
     const orgId = req.userOrgId;
 
-    if (!userId) {
+    if (!orgId) {
         return next(
             createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
         );

+ 7 - 0
server/middlewares/verifyUser.ts

@@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
 import config from "@server/lib/config";
 import { verifySession } from "@server/auth/sessions/verifySession";
 import { unauthorized } from "@server/auth/unauthorizedResponse";
+import logger from "@server/logger";
 
 export const verifySessionUserMiddleware = async (
     req: any,
@@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
 ) => {
     const { session, user } = await verifySession(req);
     if (!session || !user) {
+        if (config.getRawConfig().app.log_failed_attempts) {
+            logger.info(`User session not found. IP: ${req.ip}.`);
+        }
         return next(unauthorized());
     }
 
@@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
         .where(eq(users.userId, user.userId));
 
     if (!existingUser || !existingUser[0]) {
+        if (config.getRawConfig().app.log_failed_attempts) {
+            logger.info(`User session not found. IP: ${req.ip}.`);
+        }
         return next(
             createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
         );

+ 5 - 0
server/routers/auth/disable2fa.ts

@@ -79,6 +79,11 @@ export async function disable2fa(
         );
 
         if (!validOTP) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,

+ 19 - 1
server/routers/auth/login.ts

@@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
 
 export const loginBodySchema = z
     .object({
-        email: z.string().email(),
+        email: z
+            .string()
+            .email()
+            .transform((v) => v.toLowerCase()),
         password: z.string(),
         code: z.string().optional()
     })
@@ -68,6 +71,11 @@ export async function login(
             .from(users)
             .where(eq(users.email, email));
         if (!existingUserRes || !existingUserRes.length) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -83,6 +91,11 @@ export async function login(
             existingUser.passwordHash
         );
         if (!validPassword) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -109,6 +122,11 @@ export async function login(
             );
 
             if (!validOTP) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
+                    );
+                }
                 return next(
                     createHttpError(
                         HttpCode.BAD_REQUEST,

+ 11 - 6
server/routers/auth/logout.ts

@@ -5,18 +5,23 @@ import response from "@server/lib/response";
 import logger from "@server/logger";
 import {
     createBlankSessionTokenCookie,
-    invalidateSession,
-    SESSION_COOKIE_NAME
+    invalidateSession
 } from "@server/auth/sessions/app";
+import { verifySession } from "@server/auth/sessions/verifySession";
+import config from "@server/lib/config";
 
 export async function logout(
     req: Request,
     res: Response,
     next: NextFunction
 ): Promise<any> {
-    const sessionId = req.cookies[SESSION_COOKIE_NAME];
-
-    if (!sessionId) {
+    const { user, session } = await verifySession(req);
+    if (!user || !session) {
+        if (config.getRawConfig().app.log_failed_attempts) {
+            logger.info(
+                `Log out failed because missing or invalid session. IP: ${req.ip}.`
+            );
+        }
         return next(
             createHttpError(
                 HttpCode.BAD_REQUEST,
@@ -26,7 +31,7 @@ export async function logout(
     }
 
     try {
-        await invalidateSession(sessionId);
+        await invalidateSession(session.sessionId);
         const isSecure = req.protocol === "https";
         res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
 

+ 10 - 6
server/routers/auth/requestPasswordReset.ts

@@ -20,7 +20,10 @@ import { hashPassword } from "@server/auth/password";
 
 export const requestPasswordResetBody = z
     .object({
-        email: z.string().email()
+        email: z
+            .string()
+            .email()
+            .transform((v) => v.toLowerCase())
     })
     .strict();
 
@@ -63,10 +66,7 @@ export async function requestPasswordReset(
             );
         }
 
-        const token = generateRandomString(
-            8,
-            alphabet("0-9", "A-Z", "a-z")
-        );
+        const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
         await db.transaction(async (trx) => {
             await trx
                 .delete(passwordResetTokens)
@@ -84,6 +84,10 @@ export async function requestPasswordReset(
 
         const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
 
+        if (!config.getRawConfig().email) {
+            logger.info(`Password reset requested for ${email}. Token: ${token}.`);
+        }
+
         await sendEmail(
             ResetPasswordCode({
                 email,
@@ -91,7 +95,7 @@ export async function requestPasswordReset(
                 link: url
             }),
             {
-                from: config.getRawConfig().email?.no_reply,
+                from: config.getNoReplyEmail(),
                 to: email,
                 subject: "Reset your password"
             }

+ 20 - 2
server/routers/auth/resetPassword.ts

@@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
 
 export const resetPasswordBody = z
     .object({
-        email: z.string().email(),
+        email: z
+            .string()
+            .email()
+            .transform((v) => v.toLowerCase()),
         token: z.string(), // reset secret code
         newPassword: passwordSchema,
         code: z.string().optional() // 2fa code
@@ -57,6 +60,11 @@ export async function resetPassword(
             .where(eq(passwordResetTokens.email, email));
 
         if (!resetRequest || !resetRequest.length) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -106,6 +114,11 @@ export async function resetPassword(
             );
 
             if (!validOTP) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
+                    );
+                }
                 return next(
                     createHttpError(
                         HttpCode.BAD_REQUEST,
@@ -121,6 +134,11 @@ export async function resetPassword(
         );
 
         if (!isTokenValid) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -145,7 +163,7 @@ export async function resetPassword(
         });
 
         await sendEmail(ConfirmPasswordReset({ email }), {
-            from: config.getRawConfig().email?.no_reply,
+            from: config.getNoReplyEmail(),
             to: email,
             subject: "Password Reset Confirmation"
         });

+ 19 - 1
server/routers/auth/signup.ts

@@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
 import { passwordSchema } from "@server/auth/passwordSchema";
 
 export const signupBodySchema = z.object({
-    email: z.string().email(),
+    email: z
+        .string()
+        .email()
+        .transform((v) => v.toLowerCase()),
     password: passwordSchema,
     inviteToken: z.string().optional(),
     inviteId: z.string().optional()
@@ -60,6 +63,11 @@ export async function signup(
 
     if (config.getRawConfig().flags?.disable_signup_without_invite) {
         if (!inviteToken || !inviteId) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -84,6 +92,11 @@ export async function signup(
         }
 
         if (existingInvite.email !== email) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,
@@ -185,6 +198,11 @@ export async function signup(
         });
     } catch (e) {
         if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,

+ 5 - 0
server/routers/auth/verifyEmail.ts

@@ -75,6 +75,11 @@ export async function verifyEmail(
                     .where(eq(users.userId, user.userId));
             });
         } else {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,

+ 5 - 0
server/routers/auth/verifyTotp.ts

@@ -96,6 +96,11 @@ export async function verifyTotp(
         }
 
         if (!valid) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.BAD_REQUEST,

+ 188 - 0
server/routers/badger/exchangeSession.ts

@@ -0,0 +1,188 @@
+import HttpCode from "@server/types/HttpCode";
+import { NextFunction, Request, Response } from "express";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+import logger from "@server/logger";
+import { resourceAccessToken, resources, sessions } from "@server/db/schema";
+import db from "@server/db";
+import { eq } from "drizzle-orm";
+import {
+    createResourceSession,
+    serializeResourceSessionCookie,
+    validateResourceSessionToken
+} from "@server/auth/sessions/resource";
+import { generateSessionToken } from "@server/auth";
+import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
+import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
+import config from "@server/lib/config";
+import { response } from "@server/lib";
+
+const exchangeSessionBodySchema = z.object({
+    requestToken: z.string(),
+    host: z.string(),
+    requestIp: z.string().optional()
+});
+
+export type ExchangeSessionBodySchema = z.infer<
+    typeof exchangeSessionBodySchema
+>;
+
+export type ExchangeSessionResponse = {
+    valid: boolean;
+    cookie?: string;
+};
+
+export async function exchangeSession(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    logger.debug("Exchange session: Badger sent", req.body);
+
+    const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
+
+    if (!parsedBody.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedBody.error).toString()
+            )
+        );
+    }
+
+    try {
+        const { requestToken, host, requestIp } = parsedBody.data;
+
+        const clientIp = requestIp?.split(":")[0];
+
+        const [resource] = await db
+            .select()
+            .from(resources)
+            .where(eq(resources.fullDomain, host))
+            .limit(1);
+
+        if (!resource) {
+            return next(
+                createHttpError(
+                    HttpCode.NOT_FOUND,
+                    `Resource with host ${host} not found`
+                )
+            );
+        }
+
+        const { resourceSession: requestSession } =
+            await validateResourceSessionToken(
+                requestToken,
+                resource.resourceId
+            );
+
+        if (!requestSession) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
+                );
+            }
+            return next(
+                createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
+            );
+        }
+
+        if (!requestSession.isRequestToken) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
+                );
+            }
+            return next(
+                createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
+            );
+        }
+
+        await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
+
+        const token = generateSessionToken();
+
+        if (requestSession.userSessionId) {
+            const [res] = await db
+                .select()
+                .from(sessions)
+                .where(eq(sessions.sessionId, requestSession.userSessionId))
+                .limit(1);
+            if (res) {
+                await createResourceSession({
+                    token,
+                    resourceId: resource.resourceId,
+                    isRequestToken: false,
+                    userSessionId: requestSession.userSessionId,
+                    doNotExtend: false,
+                    expiresAt: res.expiresAt,
+                    sessionLength: SESSION_COOKIE_EXPIRES
+                });
+            }
+        } else if (requestSession.accessTokenId) {
+            const [res] = await db
+                .select()
+                .from(resourceAccessToken)
+                .where(
+                    eq(
+                        resourceAccessToken.accessTokenId,
+                        requestSession.accessTokenId
+                    )
+                )
+                .limit(1);
+            if (res) {
+                await createResourceSession({
+                    token,
+                    resourceId: resource.resourceId,
+                    isRequestToken: false,
+                    accessTokenId: requestSession.accessTokenId,
+                    doNotExtend: true,
+                    expiresAt: res.expiresAt,
+                    sessionLength: res.sessionLength
+                });
+            }
+        } else {
+            await createResourceSession({
+                token,
+                resourceId: resource.resourceId,
+                isRequestToken: false,
+                passwordId: requestSession.passwordId,
+                pincodeId: requestSession.pincodeId,
+                userSessionId: requestSession.userSessionId,
+                whitelistId: requestSession.whitelistId,
+                accessTokenId: requestSession.accessTokenId,
+                doNotExtend: false,
+                expiresAt: new Date(
+                    Date.now() + SESSION_COOKIE_EXPIRES
+                ).getTime(),
+                sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
+            });
+        }
+
+        const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
+        const cookie = serializeResourceSessionCookie(
+            cookieName,
+            resource.fullDomain!,
+            token,
+            !resource.ssl
+        );
+
+        logger.debug(JSON.stringify("Exchange cookie: " + cookie));
+        return response<ExchangeSessionResponse>(res, {
+            data: { valid: true, cookie },
+            success: true,
+            error: false,
+            message: "Session exchanged successfully",
+            status: HttpCode.OK
+        });
+    } catch (e) {
+        console.error(e);
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "Failed to exchange session"
+            )
+        );
+    }
+}

+ 1 - 0
server/routers/badger/index.ts

@@ -1 +1,2 @@
 export * from "./verifySession";
+export * from "./exchangeSession";

+ 150 - 55
server/routers/badger/verifySession.ts

@@ -4,17 +4,17 @@ import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import { response } from "@server/lib/response";
-import { validateSessionToken } from "@server/auth/sessions/app";
 import db from "@server/db";
 import {
     ResourceAccessToken,
-    resourceAccessToken,
+    ResourcePassword,
     resourcePassword,
+    ResourcePincode,
     resourcePincode,
     resources,
-    resourceWhitelist,
-    User,
-    userOrgs
+    sessions,
+    userOrgs,
+    users
 } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import config from "@server/lib/config";
@@ -27,6 +27,12 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
 import logger from "@server/logger";
 import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
 import { generateSessionToken } from "@server/auth";
+import NodeCache from "node-cache";
+
+// We'll see if this speeds anything up
+const cache = new NodeCache({
+    stdTTL: 5 // seconds
+});
 
 const verifyResourceSessionSchema = z.object({
     sessions: z.record(z.string()).optional(),
@@ -36,7 +42,8 @@ const verifyResourceSessionSchema = z.object({
     path: z.string(),
     method: z.string(),
     accessToken: z.string().optional(),
-    tls: z.boolean()
+    tls: z.boolean(),
+    requestIp: z.string().optional()
 });
 
 export type VerifyResourceSessionSchema = z.infer<
@@ -53,7 +60,7 @@ export async function verifyResourceSession(
     res: Response,
     next: NextFunction
 ): Promise<any> {
-    logger.debug("Badger sent", req.body); // remove when done testing
+    logger.debug("Verify session: Badger sent", req.body); // remove when done testing
 
     const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
 
@@ -67,26 +74,55 @@ export async function verifyResourceSession(
     }
 
     try {
-        const { sessions, host, originalRequestURL, accessToken: token } =
-            parsedBody.data;
-
-        const [result] = await db
-            .select()
-            .from(resources)
-            .leftJoin(
-                resourcePincode,
-                eq(resourcePincode.resourceId, resources.resourceId)
-            )
-            .leftJoin(
-                resourcePassword,
-                eq(resourcePassword.resourceId, resources.resourceId)
-            )
-            .where(eq(resources.fullDomain, host))
-            .limit(1);
+        const {
+            sessions,
+            host,
+            originalRequestURL,
+            requestIp,
+            accessToken: token
+        } = parsedBody.data;
+
+        const clientIp = requestIp?.split(":")[0];
+
+        const resourceCacheKey = `resource:${host}`;
+        let resourceData:
+            | {
+                  resource: Resource | null;
+                  pincode: ResourcePincode | null;
+                  password: ResourcePassword | null;
+              }
+            | undefined = cache.get(resourceCacheKey);
+
+        if (!resourceData) {
+            const [result] = await db
+                .select()
+                .from(resources)
+                .leftJoin(
+                    resourcePincode,
+                    eq(resourcePincode.resourceId, resources.resourceId)
+                )
+                .leftJoin(
+                    resourcePassword,
+                    eq(resourcePassword.resourceId, resources.resourceId)
+                )
+                .where(eq(resources.fullDomain, host))
+                .limit(1);
+
+            if (!result) {
+                logger.debug("Resource not found", host);
+                return notAllowed(res);
+            }
+
+            resourceData = {
+                resource: result.resources,
+                pincode: result.resourcePincode,
+                password: result.resourcePassword
+            };
 
-        const resource = result?.resources;
-        const pincode = result?.resourcePincode;
-        const password = result?.resourcePassword;
+            cache.set(resourceCacheKey, resourceData);
+        }
+
+        const { resource, pincode, password } = resourceData;
 
         if (!resource) {
             logger.debug("Resource not found", host);
@@ -128,6 +164,14 @@ export async function verifyResourceSession(
                 logger.debug("Access token invalid: " + error);
             }
 
+            if (!valid) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
+                    );
+                }
+            }
+
             if (valid && tokenItem) {
                 validAccessToken = tokenItem;
 
@@ -142,40 +186,44 @@ export async function verifyResourceSession(
         }
 
         if (!sessions) {
-            return notAllowed(res);
-        }
-
-        const sessionToken =
-            sessions[config.getRawConfig().server.session_cookie_name];
-
-        // check for unified login
-        if (sso && sessionToken) {
-            const { session, user } = await validateSessionToken(sessionToken);
-            if (session && user) {
-                const isAllowed = await isUserAllowedToAccessResource(
-                    user,
-                    resource
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
                 );
-
-                if (isAllowed) {
-                    logger.debug(
-                        "Resource allowed because user session is valid"
-                    );
-                    return allowed(res);
-                }
             }
+            return notAllowed(res);
         }
 
         const resourceSessionToken =
             sessions[
-                `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
+                `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
             ];
 
         if (resourceSessionToken) {
-            const { resourceSession } = await validateResourceSessionToken(
-                resourceSessionToken,
-                resource.resourceId
-            );
+            const sessionCacheKey = `session:${resourceSessionToken}`;
+            let resourceSession: any = cache.get(sessionCacheKey);
+
+            if (!resourceSession) {
+                const result = await validateResourceSessionToken(
+                    resourceSessionToken,
+                    resource.resourceId
+                );
+
+                resourceSession = result?.resourceSession;
+                cache.set(sessionCacheKey, resourceSession);
+            }
+
+            if (resourceSession?.isRequestToken) {
+                logger.debug(
+                    "Resource not allowed because session is a temporary request token"
+                );
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
+                    );
+                }
+                return notAllowed(res);
+            }
 
             if (resourceSession) {
                 if (pincode && resourceSession.pincodeId) {
@@ -208,6 +256,29 @@ export async function verifyResourceSession(
                     );
                     return allowed(res);
                 }
+
+                if (resourceSession.userSessionId && sso) {
+                    const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
+
+                    let isAllowed: boolean | undefined =
+                        cache.get(userAccessCacheKey);
+
+                    if (isAllowed === undefined) {
+                        isAllowed = await isUserAllowedToAccessResource(
+                            resourceSession.userSessionId,
+                            resource
+                        );
+
+                        cache.set(userAccessCacheKey, isAllowed);
+                    }
+
+                    if (isAllowed) {
+                        logger.debug(
+                            "Resource allowed because user session is valid"
+                        );
+                        return allowed(res);
+                    }
+                }
             }
         }
 
@@ -222,6 +293,12 @@ export async function verifyResourceSession(
         }
 
         logger.debug("No more auth to check, resource not allowed");
+
+        if (config.getRawConfig().app.log_failed_attempts) {
+            logger.info(
+                `Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
+            );
+        }
         return notAllowed(res, redirectUrl);
     } catch (e) {
         console.error(e);
@@ -272,10 +349,15 @@ async function createAccessTokenSession(
         expiresAt: tokenItem.expiresAt,
         doNotExtend: tokenItem.expiresAt ? true : false
     });
-    const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
-    const cookie = serializeResourceSessionCookie(cookieName, token);
+    const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
+    const cookie = serializeResourceSessionCookie(
+        cookieName,
+        resource.fullDomain!,
+        token,
+        !resource.ssl
+    );
     res.appendHeader("Set-Cookie", cookie);
-    logger.debug("Access token is valid, creating new session")
+    logger.debug("Access token is valid, creating new session");
     return response<VerifyUserResponse>(res, {
         data: { valid: true },
         success: true,
@@ -286,9 +368,22 @@ async function createAccessTokenSession(
 }
 
 async function isUserAllowedToAccessResource(
-    user: User,
+    userSessionId: string,
     resource: Resource
 ): Promise<boolean> {
+    const [res] = await db
+        .select()
+        .from(sessions)
+        .leftJoin(users, eq(users.userId, sessions.userId))
+        .where(eq(sessions.sessionId, userSessionId));
+
+    const user = res.user;
+    const session = res.session;
+
+    if (!user || !session) {
+        return false;
+    }
+
     if (
         config.getRawConfig().flags?.require_email_verification &&
         !user.emailVerified

+ 15 - 3
server/routers/internal.ts

@@ -1,9 +1,12 @@
 import { Router } from "express";
 import * as gerbil from "@server/routers/gerbil";
-import * as badger from "@server/routers/badger";
 import * as traefik from "@server/routers/traefik";
 import * as auth from "@server/routers/auth";
 import HttpCode from "@server/types/HttpCode";
+import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
+import { getExchangeToken } from "./resource/getExchangeToken";
+import { verifyResourceSession } from "./badger";
+import { exchangeSession } from "./badger/exchangeSession";
 
 // Root routes
 const internalRouter = Router();
@@ -13,9 +16,17 @@ internalRouter.get("/", (_, res) => {
 });
 
 internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
+
 internalRouter.get(
     "/resource-session/:resourceId/:token",
-    auth.checkResourceSession,
+    auth.checkResourceSession
+);
+
+internalRouter.post(
+    `/resource/:resourceId/get-exchange-token`,
+    verifySessionUserMiddleware,
+    verifyResourceAccess,
+    getExchangeToken
 );
 
 // Gerbil routes
@@ -29,6 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
 const badgerRouter = Router();
 internalRouter.use("/badger", badgerRouter);
 
-badgerRouter.post("/verify-session", badger.verifyResourceSession);
+badgerRouter.post("/verify-session", verifyResourceSession);
+badgerRouter.post("/exchange-session", exchangeSession);
 
 export default internalRouter;

+ 17 - 4
server/routers/newt/getToken.ts

@@ -1,6 +1,4 @@
-import {
-    generateSessionToken,
-} from "@server/auth/sessions/app";
+import { generateSessionToken } from "@server/auth/sessions/app";
 import db from "@server/db";
 import { newts } from "@server/db/schema";
 import HttpCode from "@server/types/HttpCode";
@@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
-import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt";
+import {
+    createNewtSession,
+    validateNewtSessionToken
+} from "@server/auth/sessions/newt";
 import { verifyPassword } from "@server/auth/password";
+import logger from "@server/logger";
+import config from "@server/lib/config";
 
 export const newtGetTokenBodySchema = z.object({
     newtId: z.string(),
@@ -43,6 +46,11 @@ export async function getToken(
         if (token) {
             const { session, newt } = await validateNewtSessionToken(token);
             if (session) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.`
+                    );
+                }
                 return response<null>(res, {
                     data: null,
                     success: true,
@@ -73,6 +81,11 @@ export async function getToken(
             existingNewt.secretHash
         );
         if (!validSecret) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
             );

+ 67 - 31
server/routers/newt/handleRegisterMessage.ts

@@ -1,7 +1,13 @@
 import db from "@server/db";
 import { MessageHandler } from "../ws";
-import { exitNodes, resources, sites, targets } from "@server/db/schema";
-import { eq, inArray } from "drizzle-orm";
+import {
+    exitNodes,
+    resources,
+    sites,
+    Target,
+    targets
+} from "@server/db/schema";
+import { eq, and, sql } from "drizzle-orm";
 import { addPeer, deletePeer } from "../gerbil/peers";
 import logger from "@server/logger";
 
@@ -69,37 +75,67 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
         allowedIps: [site.subnet]
     });
 
-    const siteResources = await db
-        .select()
+    const allResources = await db
+        .select({
+            // Resource fields
+            resourceId: resources.resourceId,
+            subdomain: resources.subdomain,
+            fullDomain: resources.fullDomain,
+            ssl: resources.ssl,
+            blockAccess: resources.blockAccess,
+            sso: resources.sso,
+            emailWhitelistEnabled: resources.emailWhitelistEnabled,
+            http: resources.http,
+            proxyPort: resources.proxyPort,
+            protocol: resources.protocol,
+            // Targets as a subquery
+            targets: sql<string>`json_group_array(json_object(
+          'targetId', ${targets.targetId},
+          'ip', ${targets.ip},
+          'method', ${targets.method},
+          'port', ${targets.port},
+          'internalPort', ${targets.internalPort},
+          'enabled', ${targets.enabled}
+        ))`.as("targets")
+        })
         .from(resources)
-        .where(eq(resources.siteId, siteId));
-
-    // get the targets from the resourceIds
-    const siteTargets = await db
-        .select()
-        .from(targets)
-        .where(
-            inArray(
-                targets.resourceId,
-                siteResources.map((resource) => resource.resourceId)
+        .leftJoin(
+            targets,
+            and(
+                eq(targets.resourceId, resources.resourceId),
+                eq(targets.enabled, true)
             )
-        );
-
-    const udpTargets = siteTargets
-        .filter((target) => target.protocol === "udp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${
-                target.ip
-            }:${target.port}`;
-        });
-
-    const tcpTargets = siteTargets
-        .filter((target) => target.protocol === "tcp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${
-                target.ip
-            }:${target.port}`;
-        });
+        )
+        .groupBy(resources.resourceId);
+
+    let tcpTargets: string[] = [];
+    let udpTargets: string[] = [];
+
+    for (const resource of allResources) {
+        const targets = JSON.parse(resource.targets);
+        if (!targets || targets.length === 0) {
+            continue;
+        }
+        if (resource.protocol === "tcp") {
+            tcpTargets = tcpTargets.concat(
+                targets.map(
+                    (target: Target) =>
+                        `${
+                            target.internalPort ? target.internalPort + ":" : ""
+                        }${target.ip}:${target.port}`
+                )
+            );
+        } else {
+            udpTargets = tcpTargets.concat(
+                targets.map(
+                    (target: Target) =>
+                        `${
+                            target.internalPort ? target.internalPort + ":" : ""
+                        }${target.ip}:${target.port}`
+                )
+            );
+        }
+    }
 
     return {
         message: {

+ 36 - 65
server/routers/newt/targets.ts

@@ -1,73 +1,44 @@
 import { Target } from "@server/db/schema";
 import { sendToClient } from "../ws";
 
-export async function addTargets(newtId: string, targets: Target[]): Promise<void> {
+export async function addTargets(
+    newtId: string,
+    targets: Target[],
+    protocol: string
+): Promise<void> {
     //create a list of udp and tcp targets
-    const udpTargets = targets
-        .filter((target) => target.protocol === "udp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
-        });
-
-    const tcpTargets = targets
-        .filter((target) => target.protocol === "tcp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
-        });
-
-    if (udpTargets.length > 0) {
-        const payload = {
-            type: `newt/udp/add`,
-            data: {
-                targets: udpTargets,
-            },
-        };
-        sendToClient(newtId, payload);
-    }
-
-    if (tcpTargets.length > 0) {
-        const payload = {
-            type: `newt/tcp/add`,
-            data: {
-                targets: tcpTargets,
-            },
-        };
-        sendToClient(newtId, payload);
-    }
+    const payloadTargets = targets.map((target) => {
+        return `${target.internalPort ? target.internalPort + ":" : ""}${
+            target.ip
+        }:${target.port}`;
+    });
+
+    const payload = {
+        type: `newt/${protocol}/add`,
+        data: {
+            targets: payloadTargets
+        }
+    };
+    sendToClient(newtId, payload);
 }
 
-
-export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
+export async function removeTargets(
+    newtId: string,
+    targets: Target[],
+    protocol: string
+): Promise<void> {
     //create a list of udp and tcp targets
-    const udpTargets = targets
-        .filter((target) => target.protocol === "udp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
-        });
-
-    const tcpTargets = targets
-        .filter((target) => target.protocol === "tcp")
-        .map((target) => {
-            return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
-        });
-
-    if (udpTargets.length > 0) {
-        const payload = {
-            type: `newt/udp/remove`,
-            data: {
-                targets: udpTargets,
-            },
-        };
-        sendToClient(newtId, payload);
-    }
-
-    if (tcpTargets.length > 0) {
-        const payload = {
-            type: `newt/tcp/remove`,
-            data: {
-                targets: tcpTargets,
-            },
-        };
-        sendToClient(newtId, payload);
-    }
+    const payloadTargets = targets.map((target) => {
+        return `${target.internalPort ? target.internalPort + ":" : ""}${
+            target.ip
+        }:${target.port}`;
+    });
+
+    const payload = {
+        type: `newt/${protocol}/remove`,
+        data: {
+            targets: payloadTargets
+        }
+    };
+    sendToClient(newtId, payload);
 }

+ 13 - 13
server/routers/resource/authWithAccessToken.ts

@@ -1,20 +1,17 @@
 import { generateSessionToken } from "@server/auth/sessions/app";
 import db from "@server/db";
-import { resourceAccessToken, resources } from "@server/db/schema";
+import { resources } from "@server/db/schema";
 import HttpCode from "@server/types/HttpCode";
 import response from "@server/lib/response";
-import { eq, and } from "drizzle-orm";
+import { eq } from "drizzle-orm";
 import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
-import {
-    createResourceSession,
-    serializeResourceSessionCookie
-} from "@server/auth/sessions/resource";
-import config from "@server/lib/config";
+import { createResourceSession } from "@server/auth/sessions/resource";
 import logger from "@server/logger";
 import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
+import config from "@server/lib/config";
 
 const authWithAccessTokenBodySchema = z
     .object({
@@ -86,6 +83,11 @@ export async function authWithAccessToken(
         });
 
         if (!valid) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(
                     HttpCode.UNAUTHORIZED,
@@ -108,13 +110,11 @@ export async function authWithAccessToken(
             resourceId,
             token,
             accessTokenId: tokenItem.accessTokenId,
-            sessionLength: tokenItem.sessionLength,
-            expiresAt: tokenItem.expiresAt,
-            doNotExtend: tokenItem.expiresAt ? true : false
+            isRequestToken: true,
+            expiresAt: Date.now() + 1000 * 30, // 30 seconds
+            sessionLength: 1000 * 30,
+            doNotExtend: true
         });
-        const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
-        const cookie = serializeResourceSessionCookie(cookieName, token);
-        res.appendHeader("Set-Cookie", cookie);
 
         return response<AuthWithAccessTokenResponse>(res, {
             data: {

+ 13 - 10
server/routers/resource/authWithPassword.ts

@@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
-import {
-    createResourceSession,
-    serializeResourceSessionCookie
-} from "@server/auth/sessions/resource";
-import config from "@server/lib/config";
+import { createResourceSession } from "@server/auth/sessions/resource";
 import logger from "@server/logger";
 import { verifyPassword } from "@server/auth/password";
+import config from "@server/lib/config";
 
 export const authWithPasswordBodySchema = z
     .object({
@@ -84,7 +81,7 @@ export async function authWithPassword(
 
         if (!org) {
             return next(
-                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
+                createHttpError(HttpCode.BAD_REQUEST, "Org does not exist")
             );
         }
 
@@ -111,6 +108,11 @@ export async function authWithPassword(
             definedPassword.passwordHash
         );
         if (!validPassword) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
             );
@@ -120,11 +122,12 @@ export async function authWithPassword(
         await createResourceSession({
             resourceId,
             token,
-            passwordId: definedPassword.passwordId
+            passwordId: definedPassword.passwordId,
+            isRequestToken: true,
+            expiresAt: Date.now() + 1000 * 30, // 30 seconds
+            sessionLength: 1000 * 30,
+            doNotExtend: true
         });
-        const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
-        const cookie = serializeResourceSessionCookie(cookieName, token);
-        res.appendHeader("Set-Cookie", cookie);
 
         return response<AuthWithPasswordResponse>(res, {
             data: {

+ 14 - 20
server/routers/resource/authWithPincode.ts

@@ -1,29 +1,17 @@
-import { verify } from "@node-rs/argon2";
 import { generateSessionToken } from "@server/auth/sessions/app";
 import db from "@server/db";
-import {
-    orgs,
-    resourceOtp,
-    resourcePincode,
-    resources,
-    resourceWhitelist
-} from "@server/db/schema";
+import { orgs, resourcePincode, resources } from "@server/db/schema";
 import HttpCode from "@server/types/HttpCode";
 import response from "@server/lib/response";
-import { and, eq } from "drizzle-orm";
+import { eq } from "drizzle-orm";
 import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
-import {
-    createResourceSession,
-    serializeResourceSessionCookie
-} from "@server/auth/sessions/resource";
+import { createResourceSession } from "@server/auth/sessions/resource";
 import logger from "@server/logger";
-import config from "@server/lib/config";
-import { AuthWithPasswordResponse } from "./authWithPassword";
-import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
 import { verifyPassword } from "@server/auth/password";
+import config from "@server/lib/config";
 
 export const authWithPincodeBodySchema = z
     .object({
@@ -119,6 +107,11 @@ export async function authWithPincode(
             definedPincode.pincodeHash
         );
         if (!validPincode) {
+            if (config.getRawConfig().app.log_failed_attempts) {
+                logger.info(
+                    `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
+                );
+            }
             return next(
                 createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
             );
@@ -128,11 +121,12 @@ export async function authWithPincode(
         await createResourceSession({
             resourceId,
             token,
-            pincodeId: definedPincode.pincodeId
+            pincodeId: definedPincode.pincodeId,
+            isRequestToken: true,
+            expiresAt: Date.now() + 1000 * 30, // 30 seconds
+            sessionLength: 1000 * 30,
+            doNotExtend: true
         });
-        const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
-        const cookie = serializeResourceSessionCookie(cookieName, token);
-        res.appendHeader("Set-Cookie", cookie);
 
         return response<AuthWithPincodeResponse>(res, {
             data: {

+ 59 - 21
server/routers/resource/authWithWhitelist.ts

@@ -3,7 +3,6 @@ import db from "@server/db";
 import {
     orgs,
     resourceOtp,
-    resourcePassword,
     resources,
     resourceWhitelist
 } from "@server/db/schema";
@@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
 import createHttpError from "http-errors";
 import { z } from "zod";
 import { fromError } from "zod-validation-error";
-import {
-    createResourceSession,
-    serializeResourceSessionCookie
-} from "@server/auth/sessions/resource";
-import config from "@server/lib/config";
+import { createResourceSession } from "@server/auth/sessions/resource";
 import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
 import logger from "@server/logger";
+import config from "@server/lib/config";
 
 const authWithWhitelistBodySchema = z
     .object({
-        email: z.string().email(),
+        email: z
+            .string()
+            .email()
+            .transform((v) => v.toLowerCase()),
         otp: z.string().optional()
     })
     .strict();
@@ -90,20 +89,53 @@ export async function authWithWhitelist(
             .leftJoin(orgs, eq(orgs.orgId, resources.orgId))
             .limit(1);
 
-        const resource = result?.resources;
-        const org = result?.orgs;
-        const whitelistedEmail = result?.resourceWhitelist;
+        let resource = result?.resources;
+        let org = result?.orgs;
+        let whitelistedEmail = result?.resourceWhitelist;
 
         if (!whitelistedEmail) {
-            return next(
-                createHttpError(
-                    HttpCode.UNAUTHORIZED,
-                    createHttpError(
-                        HttpCode.BAD_REQUEST,
-                        "Email is not whitelisted"
+            // if email is not found, check for wildcard email
+            const wildcard = "*@" + email.split("@")[1];
+
+            logger.debug("Checking for wildcard email: " + wildcard);
+
+            const [result] = await db
+                .select()
+                .from(resourceWhitelist)
+                .where(
+                    and(
+                        eq(resourceWhitelist.resourceId, resourceId),
+                        eq(resourceWhitelist.email, wildcard)
                     )
                 )
-            );
+                .leftJoin(
+                    resources,
+                    eq(resources.resourceId, resourceWhitelist.resourceId)
+                )
+                .leftJoin(orgs, eq(orgs.orgId, resources.orgId))
+                .limit(1);
+
+            resource = result?.resources;
+            org = result?.orgs;
+            whitelistedEmail = result?.resourceWhitelist;
+
+            // if wildcard is still not found, return unauthorized
+            if (!whitelistedEmail) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
+                    );
+                }
+                return next(
+                    createHttpError(
+                        HttpCode.UNAUTHORIZED,
+                        createHttpError(
+                            HttpCode.BAD_REQUEST,
+                            "Email is not whitelisted"
+                        )
+                    )
+                );
+            }
         }
 
         if (!org) {
@@ -125,6 +157,11 @@ export async function authWithWhitelist(
                 otp
             );
             if (!isValidCode) {
+                if (config.getRawConfig().app.log_failed_attempts) {
+                    logger.info(
+                        `Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.`
+                    );
+                }
                 return next(
                     createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
                 );
@@ -175,11 +212,12 @@ export async function authWithWhitelist(
         await createResourceSession({
             resourceId,
             token,
-            whitelistId: whitelistedEmail.whitelistId
+            whitelistId: whitelistedEmail.whitelistId,
+            isRequestToken: true,
+            expiresAt: Date.now() + 1000 * 30, // 30 seconds
+            sessionLength: 1000 * 30,
+            doNotExtend: true
         });
-        const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
-        const cookie = serializeResourceSessionCookie(cookieName, token);
-        res.appendHeader("Set-Cookie", cookie);
 
         return response<AuthWithWhitelistResponse>(res, {
             data: {

+ 86 - 17
server/routers/resource/createResource.ts

@@ -16,8 +16,8 @@ import createHttpError from "http-errors";
 import { eq, and } from "drizzle-orm";
 import stoi from "@server/lib/stoi";
 import { fromError } from "zod-validation-error";
-import { subdomainSchema } from "@server/schemas/subdomainSchema";
 import logger from "@server/logger";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
 
 const createResourceParamsSchema = z
     .object({
@@ -28,10 +28,42 @@ const createResourceParamsSchema = z
 
 const createResourceSchema = z
     .object({
+        subdomain: z.string().optional(),
         name: z.string().min(1).max(255),
-        subdomain: subdomainSchema
+        siteId: z.number(),
+        http: z.boolean(),
+        protocol: z.string(),
+        proxyPort: z.number().optional()
     })
-    .strict();
+    .refine(
+        (data) => {
+            if (!data.http) {
+                return z
+                    .number()
+                    .int()
+                    .min(1)
+                    .max(65535)
+                    .safeParse(data.proxyPort).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid port number",
+            path: ["proxyPort"]
+        }
+    )
+    .refine(
+        (data) => {
+            if (data.http) {
+                return subdomainSchema.safeParse(data.subdomain).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid subdomain",
+            path: ["subdomain"]
+        }
+    );
 
 export type CreateResourceResponse = Resource;
 
@@ -51,7 +83,7 @@ export async function createResource(
             );
         }
 
-        let { name, subdomain } = parsedBody.data;
+        let { name, subdomain, protocol, proxyPort, http } = parsedBody.data;
 
         // Validate request params
         const parsedParams = createResourceParamsSchema.safeParse(req.params);
@@ -89,15 +121,64 @@ export async function createResource(
         }
 
         const 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
+                .select()
+                .from(resources)
+                .where(
+                    and(
+                        eq(resources.protocol, protocol),
+                        eq(resources.proxyPort, proxyPort!)
+                    )
+                );
+
+            if (existingResource.length > 0) {
+                return next(
+                    createHttpError(
+                        HttpCode.CONFLICT,
+                        "Resource with that protocol and port already exists"
+                    )
+                );
+            }
+        } else {
+            if (proxyPort === 443 || proxyPort === 80) {
+                return next(
+                    createHttpError(
+                        HttpCode.BAD_REQUEST,
+                        "Port 80 and 443 are reserved for https resources"
+                    )
+                );
+            }
+
+            // make sure the full domain is unique
+            const existingResource = await db
+                .select()
+                .from(resources)
+                .where(eq(resources.fullDomain, fullDomain));
+
+            if (existingResource.length > 0) {
+                return next(
+                    createHttpError(
+                        HttpCode.CONFLICT,
+                        "Resource with that domain already exists"
+                    )
+                );
+            }
+        }
+
         await db.transaction(async (trx) => {
             const newResource = await trx
                 .insert(resources)
                 .values({
                     siteId,
-                    fullDomain,
+                    fullDomain: http ? fullDomain : null,
                     orgId,
                     name,
                     subdomain,
+                    http,
+                    protocol,
+                    proxyPort,
                     ssl: true
                 })
                 .returning();
@@ -135,18 +216,6 @@ export async function createResource(
             });
         });
     } catch (error) {
-        if (
-            error instanceof SqliteError &&
-            error.code === "SQLITE_CONSTRAINT_UNIQUE"
-        ) {
-            return next(
-                createHttpError(
-                    HttpCode.CONFLICT,
-                    "Resource with that subdomain already exists"
-                )
-            );
-        }
-
         logger.error(error);
         return next(
             createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

+ 1 - 1
server/routers/resource/deleteResource.ts

@@ -103,7 +103,7 @@ export async function deleteResource(
                     .where(eq(newts.siteId, site.siteId))
                     .limit(1);
 
-                removeTargets(newt.newtId, targetsToBeRemoved);
+                removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
             }
         }
 

+ 109 - 0
server/routers/resource/getExchangeToken.ts

@@ -0,0 +1,109 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import { resources } from "@server/db/schema";
+import { eq } from "drizzle-orm";
+import { createResourceSession } from "@server/auth/sessions/resource";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { fromError } from "zod-validation-error";
+import logger from "@server/logger";
+import { generateSessionToken } from "@server/auth/sessions/app";
+import config from "@server/lib/config";
+import {
+    encodeHexLowerCase
+} from "@oslojs/encoding";
+import { sha256 } from "@oslojs/crypto/sha2";
+import { response } from "@server/lib";
+
+const getExchangeTokenParams = z
+    .object({
+        resourceId: z
+            .string()
+            .transform(Number)
+            .pipe(z.number().int().positive())
+    })
+    .strict();
+
+export type GetExchangeTokenResponse = {
+    requestToken: string;
+};
+
+export async function getExchangeToken(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    try {
+        const parsedParams = getExchangeTokenParams.safeParse(req.params);
+        if (!parsedParams.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    fromError(parsedParams.error).toString()
+                )
+            );
+        }
+
+        const { resourceId } = parsedParams.data;
+
+        const resource = await db
+            .select()
+            .from(resources)
+            .where(eq(resources.resourceId, resourceId))
+            .limit(1);
+
+        if (resource.length === 0) {
+            return next(
+                createHttpError(
+                    HttpCode.NOT_FOUND,
+                    `Resource with ID ${resourceId} not found`
+                )
+            );
+        }
+
+        const ssoSession =
+            req.cookies[config.getRawConfig().server.session_cookie_name];
+        if (!ssoSession) {
+            logger.debug(ssoSession);
+            return next(
+                createHttpError(
+                    HttpCode.UNAUTHORIZED,
+                    "Missing SSO session cookie"
+                )
+            );
+        }
+
+        const sessionId = encodeHexLowerCase(
+            sha256(new TextEncoder().encode(ssoSession))
+        );
+
+        const token = generateSessionToken();
+        await createResourceSession({
+            resourceId,
+            token,
+            userSessionId: sessionId,
+            isRequestToken: true,
+            expiresAt: Date.now() + 1000 * 30, // 30 seconds
+            sessionLength: 1000 * 30,
+            doNotExtend: true
+        });
+
+        logger.debug("Request token created successfully");
+
+        return response<GetExchangeTokenResponse>(res, {
+            data: {
+                requestToken: token
+            },
+            success: true,
+            error: false,
+            message: "Request token created successfully",
+            status: HttpCode.OK
+        });
+    } catch (error) {
+        logger.error(error);
+        return next(
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+        );
+    }
+}

+ 1 - 0
server/routers/resource/index.ts

@@ -16,3 +16,4 @@ export * from "./setResourceWhitelist";
 export * from "./getResourceWhitelist";
 export * from "./authWithWhitelist";
 export * from "./authWithAccessToken";
+export * from "./getExchangeToken";

+ 8 - 2
server/routers/resource/listResources.ts

@@ -63,7 +63,10 @@ function queryResources(
                 passwordId: resourcePassword.passwordId,
                 pincodeId: resourcePincode.pincodeId,
                 sso: resources.sso,
-                whitelist: resources.emailWhitelistEnabled
+                whitelist: resources.emailWhitelistEnabled,
+                http: resources.http,
+                protocol: resources.protocol,
+                proxyPort: resources.proxyPort
             })
             .from(resources)
             .leftJoin(sites, eq(resources.siteId, sites.siteId))
@@ -93,7 +96,10 @@ function queryResources(
                 passwordId: resourcePassword.passwordId,
                 sso: resources.sso,
                 pincodeId: resourcePincode.pincodeId,
-                whitelist: resources.emailWhitelistEnabled
+                whitelist: resources.emailWhitelistEnabled,
+                http: resources.http,
+                protocol: resources.protocol,
+                proxyPort: resources.proxyPort
             })
             .from(resources)
             .leftJoin(sites, eq(resources.siteId, sites.siteId))

+ 14 - 1
server/routers/resource/setResourceWhitelist.ts

@@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
 
 const setResourceWhitelistBodySchema = z
     .object({
-        emails: z.array(z.string().email()).max(50)
+        emails: z
+            .array(
+                z
+                    .string()
+                    .email()
+                    .or(
+                        z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
+                            message:
+                                "Invalid email address. Wildcard (*) must be the entire local part."
+                        })
+                    )
+            )
+            .max(50)
+            .transform((v) => v.map((e) => e.toLowerCase()))
     })
     .strict();
 

+ 5 - 1
server/routers/resource/updateResource.ts

@@ -26,8 +26,8 @@ const updateResourceBodySchema = z
         ssl: z.boolean().optional(),
         sso: z.boolean().optional(),
         blockAccess: z.boolean().optional(),
+        proxyPort: z.number().int().min(1).max(65535).optional(),
         emailWhitelistEnabled: z.boolean().optional()
-        // siteId: z.number(),
     })
     .strict()
     .refine((data) => Object.keys(data).length > 0, {
@@ -111,6 +111,10 @@ export async function updateResource(
             );
         }
 
+        if (resource[0].resources.ssl !== updatedResource[0].ssl) {
+            // invalidate all sessions?
+        }
+
         return response(res, {
             data: updatedResource[0],
             success: true,

+ 3 - 8
server/routers/target/createTarget.ts

@@ -53,9 +53,8 @@ const createTargetParamsSchema = z
 const createTargetSchema = z
     .object({
         ip: domainSchema,
-        method: z.string().min(1).max(10),
+        method: z.string().optional().nullable(),
         port: z.number().int().min(1).max(65535),
-        protocol: z.string().optional(),
         enabled: z.boolean().default(true)
     })
     .strict();
@@ -94,9 +93,7 @@ export async function createTarget(
 
         // get the resource
         const [resource] = await db
-            .select({
-                siteId: resources.siteId
-            })
+            .select()
             .from(resources)
             .where(eq(resources.resourceId, resourceId));
 
@@ -130,7 +127,6 @@ export async function createTarget(
                 .insert(targets)
                 .values({
                     resourceId,
-                    protocol: "tcp", // hard code for now
                     ...targetData
                 })
                 .returning();
@@ -163,7 +159,6 @@ export async function createTarget(
                 .insert(targets)
                 .values({
                     resourceId,
-                    protocol: "tcp", // hard code for now
                     internalPort,
                     ...targetData
                 })
@@ -186,7 +181,7 @@ export async function createTarget(
                         .where(eq(newts.siteId, site.siteId))
                         .limit(1);
 
-                    addTargets(newt.newtId, newTarget);
+                    addTargets(newt.newtId, newTarget, resource.protocol);
                 }
             }
         }

+ 2 - 4
server/routers/target/deleteTarget.ts

@@ -50,9 +50,7 @@ export async function deleteTarget(
         }
         // get the resource
         const [resource] = await db
-            .select({
-                siteId: resources.siteId
-            })
+            .select()
             .from(resources)
             .where(eq(resources.resourceId, deletedTarget.resourceId!));
 
@@ -110,7 +108,7 @@ export async function deleteTarget(
                     .where(eq(newts.siteId, site.siteId))
                     .limit(1);
 
-                removeTargets(newt.newtId, [deletedTarget]);
+                removeTargets(newt.newtId, [deletedTarget], resource.protocol);
             }
         }
 

+ 0 - 1
server/routers/target/listTargets.ts

@@ -40,7 +40,6 @@ function queryTargets(resourceId: number) {
             ip: targets.ip,
             method: targets.method,
             port: targets.port,
-            protocol: targets.protocol,
             enabled: targets.enabled,
             resourceId: targets.resourceId
             // resourceName: resources.name,

+ 3 - 5
server/routers/target/updateTarget.ts

@@ -49,7 +49,7 @@ const updateTargetParamsSchema = z
 const updateTargetBodySchema = z
     .object({
         ip: domainSchema.optional(),
-        method: z.string().min(1).max(10).optional(),
+        method: z.string().min(1).max(10).optional().nullable(),
         port: z.number().int().min(1).max(65535).optional(),
         enabled: z.boolean().optional()
     })
@@ -103,9 +103,7 @@ export async function updateTarget(
 
         // get the resource
         const [resource] = await db
-            .select({
-                siteId: resources.siteId
-            })
+            .select()
             .from(resources)
             .where(eq(resources.resourceId, target.resourceId!));
 
@@ -167,7 +165,7 @@ export async function updateTarget(
                     .where(eq(newts.siteId, site.siteId))
                     .limit(1);
 
-                addTargets(newt.newtId, [updatedTarget]);
+                addTargets(newt.newtId, [updatedTarget], resource.protocol);
             }
         }
         return response(res, {

+ 226 - 131
server/routers/traefik/getTraefikConfig.ts

@@ -1,174 +1,269 @@
 import { Request, Response } from "express";
 import db from "@server/db";
-import * as schema from "@server/db/schema";
-import { and, eq, isNotNull } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
 import logger from "@server/logger";
 import HttpCode from "@server/types/HttpCode";
 import config from "@server/lib/config";
+import { orgs, resources, sites, Target, targets } from "@server/db/schema";
+import { sql } from "drizzle-orm";
 
 export async function traefikConfigProvider(
     _: Request,
-    res: Response,
+    res: Response
 ): Promise<any> {
     try {
-        const all = await db
-            .select()
-            .from(schema.targets)
-            .innerJoin(
-                schema.resources,
-                eq(schema.targets.resourceId, schema.resources.resourceId),
-            )
-            .innerJoin(
-                schema.orgs,
-                eq(schema.resources.orgId, schema.orgs.orgId),
-            )
-            .innerJoin(
-                schema.sites,
-                eq(schema.sites.siteId, schema.resources.siteId),
-            )
-            .where(
+        const allResources = await db
+            .select({
+                // Resource fields
+                resourceId: resources.resourceId,
+                subdomain: resources.subdomain,
+                fullDomain: resources.fullDomain,
+                ssl: resources.ssl,
+                blockAccess: resources.blockAccess,
+                sso: resources.sso,
+                emailWhitelistEnabled: resources.emailWhitelistEnabled,
+                http: resources.http,
+                proxyPort: resources.proxyPort,
+                protocol: resources.protocol,
+                // Site fields
+                site: {
+                    siteId: sites.siteId,
+                    type: sites.type,
+                    subnet: sites.subnet
+                },
+                // Org fields
+                org: {
+                    orgId: orgs.orgId,
+                    domain: orgs.domain
+                },
+                // Targets as a subquery
+                targets: sql<string>`json_group_array(json_object(
+          'targetId', ${targets.targetId},
+          'ip', ${targets.ip},
+          'method', ${targets.method},
+          'port', ${targets.port},
+          'internalPort', ${targets.internalPort},
+          'enabled', ${targets.enabled}
+        ))`.as("targets")
+            })
+            .from(resources)
+            .innerJoin(sites, eq(sites.siteId, resources.siteId))
+            .innerJoin(orgs, eq(resources.orgId, orgs.orgId))
+            .leftJoin(
+                targets,
                 and(
-                    eq(schema.targets.enabled, true),
-                    isNotNull(schema.resources.subdomain),
-                    isNotNull(schema.orgs.domain),
-                ),
-            );
+                    eq(targets.resourceId, resources.resourceId),
+                    eq(targets.enabled, true)
+                )
+            )
+            .groupBy(resources.resourceId);
 
-        if (!all.length) {
+        if (!allResources.length) {
             return res.status(HttpCode.OK).json({});
         }
 
         const badgerMiddlewareName = "badger";
-        const redirectMiddlewareName = "redirect-to-https";
-
-        const http: any = {
-            routers: {},
-            services: {},
-            middlewares: {
-                [badgerMiddlewareName]: {
-                    plugin: {
-                        [badgerMiddlewareName]: {
-                            apiBaseUrl: new URL(
-                                "/api/v1",
-                                `http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
-                            ).href,
-                            resourceSessionCookieName:
-                                config.getRawConfig().server.resource_session_cookie_name,
-                            userSessionCookieName:
-                                config.getRawConfig().server.session_cookie_name,
-                            accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param,
-                        },
-                    },
-                },
-                [redirectMiddlewareName]: {
-                    redirectScheme: {
-                        scheme: "https",
-                        permanent: true,
+        const redirectHttpsMiddlewareName = "redirect-to-https";
+
+        const config_output: any = {
+            http: {
+                middlewares: {
+                    [badgerMiddlewareName]: {
+                        plugin: {
+                            [badgerMiddlewareName]: {
+                                apiBaseUrl: new URL(
+                                    "/api/v1",
+                                    `http://${config.getRawConfig().server.internal_hostname}:${
+                                        config.getRawConfig().server
+                                            .internal_port
+                                    }`
+                                ).href,
+                                userSessionCookieName:
+                                    config.getRawConfig().server
+                                        .session_cookie_name,
+                                accessTokenQueryParam:
+                                    config.getRawConfig().server
+                                        .resource_access_token_param,
+                                resourceSessionRequestParam:
+                                    config.getRawConfig().server
+                                        .resource_session_request_param
+                            }
+                        }
                     },
-                },
-            },
+                    [redirectHttpsMiddlewareName]: {
+                        redirectScheme: {
+                            scheme: "https"
+                        }
+                    }
+                }
+            }
         };
-        for (const item of all) {
-            const target = item.targets;
-            const resource = item.resources;
-            const site = item.sites;
-            const org = item.orgs;
 
-            const routerName = `${target.targetId}-router`;
-            const serviceName = `${target.targetId}-service`;
+        for (const resource of allResources) {
+            const targets = JSON.parse(resource.targets);
+            const site = resource.site;
+            const org = resource.org;
 
-            if (!resource || !resource.subdomain) {
-                continue;
-            }
-
-            if (!org || !org.domain) {
+            if (!org.domain) {
                 continue;
             }
 
+            const routerName = `${resource.resourceId}-router`;
+            const serviceName = `${resource.resourceId}-service`;
             const fullDomain = `${resource.subdomain}.${org.domain}`;
 
-            const domainParts = fullDomain.split(".");
-            let wildCard;
-            if (domainParts.length <= 2) {
-                wildCard = `*.${domainParts.join(".")}`;
-            } else {
-                wildCard = `*.${domainParts.slice(1).join(".")}`;
-            }
+            if (resource.http) {
+                // HTTP configuration remains the same
+                if (!resource.subdomain) {
+                    continue;
+                }
 
-            const tls = {
-                certResolver: config.getRawConfig().traefik.cert_resolver,
-                ...(config.getRawConfig().traefik.prefer_wildcard_cert
-                    ? {
-                          domains: [
-                              {
-                                  main: wildCard,
-                              },
-                          ],
-                      }
-                    : {}),
-            };
-
-            http.routers![routerName] = {
-                entryPoints: [
-                    resource.ssl
-                        ? config.getRawConfig().traefik.https_entrypoint
-                        : config.getRawConfig().traefik.http_entrypoint,
-                ],
-                middlewares: [badgerMiddlewareName],
-                service: serviceName,
-                rule: `Host(\`${fullDomain}\`)`,
-                ...(resource.ssl ? { tls } : {}),
-            };
-
-            if (resource.ssl) {
-                // this is a redirect router; all it does is redirect to the https version if tls is enabled
-                http.routers![routerName + "-redirect"] = {
-                    entryPoints: [config.getRawConfig().traefik.http_entrypoint],
-                    middlewares: [redirectMiddlewareName],
+                if (
+                    targets.filter(
+                        (target: Target) => target.internalPort != null
+                    ).length == 0
+                ) {
+                    continue;
+                }
+
+                // add routers and services empty objects if they don't exist
+                if (!config_output.http.routers) {
+                    config_output.http.routers = {};
+                }
+
+                if (!config_output.http.services) {
+                    config_output.http.services = {};
+                }
+
+                const domainParts = fullDomain.split(".");
+                let wildCard;
+                if (domainParts.length <= 2) {
+                    wildCard = `*.${domainParts.join(".")}`;
+                } else {
+                    wildCard = `*.${domainParts.slice(1).join(".")}`;
+                }
+
+                const tls = {
+                    certResolver: config.getRawConfig().traefik.cert_resolver,
+                    ...(config.getRawConfig().traefik.prefer_wildcard_cert
+                        ? {
+                              domains: [
+                                  {
+                                      main: wildCard
+                                  }
+                              ]
+                          }
+                        : {})
+                };
+
+                const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
+
+                config_output.http.routers![routerName] = {
+                    entryPoints: [
+                        resource.ssl
+                            ? config.getRawConfig().traefik.https_entrypoint
+                            : config.getRawConfig().traefik.http_entrypoint
+                    ],
+                    middlewares: [badgerMiddlewareName, ...additionalMiddlewares],
                     service: serviceName,
                     rule: `Host(\`${fullDomain}\`)`,
+                    ...(resource.ssl ? { tls } : {})
                 };
-            }
 
-            if (site.type === "newt") {
-                const ip = site.subnet.split("/")[0];
-                http.services![serviceName] = {
-                    loadBalancer: {
-                        servers: [
-                            {
-                                url: `${target.method}://${ip}:${target.internalPort}`,
-                            },
+                if (resource.ssl) {
+                    config_output.http.routers![routerName + "-redirect"] = {
+                        entryPoints: [
+                            config.getRawConfig().traefik.http_entrypoint
                         ],
-                    },
-                };
-            } else if (site.type === "wireguard") {
-                http.services![serviceName] = {
+                        middlewares: [redirectHttpsMiddlewareName],
+                        service: serviceName,
+                        rule: `Host(\`${fullDomain}\`)`
+                    };
+                }
+
+                config_output.http.services![serviceName] = {
                     loadBalancer: {
-                        servers: [
-                            {
-                                url: `${target.method}://${target.ip}:${target.port}`,
-                            },
-                        ],
-                    },
+                        servers: targets
+                            .filter(
+                                (target: Target) => target.internalPort != null
+                            )
+                            .map((target: Target) => {
+                                if (
+                                    site.type === "local" ||
+                                    site.type === "wireguard"
+                                ) {
+                                    return {
+                                        url: `${target.method}://${target.ip}:${target.port}`
+                                    };
+                                } else if (site.type === "newt") {
+                                    const ip = site.subnet.split("/")[0];
+                                    return {
+                                        url: `${target.method}://${ip}:${target.internalPort}`
+                                    };
+                                }
+                            })
+                    }
+                };
+            } else {
+                // Non-HTTP (TCP/UDP) configuration
+                const protocol = resource.protocol.toLowerCase();
+                const port = resource.proxyPort;
+
+                if (!port) {
+                    continue;
+                }
+
+                if (
+                    targets.filter(
+                        (target: Target) => target.internalPort != null
+                    ).length == 0
+                ) {
+                    continue;
+                }
+
+                if (!config_output[protocol]) {
+                    config_output[protocol] = {
+                        routers: {},
+                        services: {}
+                    };
+                }
+
+                config_output[protocol].routers[routerName] = {
+                    entryPoints: [`${protocol}-${port}`],
+                    service: serviceName,
+                    ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
                 };
-            } else if (site.type === "local") {
-                http.services![serviceName] = {
+
+                config_output[protocol].services[serviceName] = {
                     loadBalancer: {
-                        servers: [
-                            {
-                                url: `${target.method}://${target.ip}:${target.port}`,
-                            },
-                        ],
-                    },
+                        servers: targets
+                            .filter(
+                                (target: Target) => target.internalPort != null
+                            )
+                            .map((target: Target) => {
+                                if (
+                                    site.type === "local" ||
+                                    site.type === "wireguard"
+                                ) {
+                                    return {
+                                        address: `${target.ip}:${target.port}`
+                                    };
+                                } else if (site.type === "newt") {
+                                    const ip = site.subnet.split("/")[0];
+                                    return {
+                                        address: `${ip}:${target.internalPort}`
+                                    };
+                                }
+                            })
+                    }
                 };
             }
         }
-
-        return res.status(HttpCode.OK).json({ http });
+        return res.status(HttpCode.OK).json(config_output);
     } catch (e) {
         logger.error(`Failed to build traefik config: ${e}`);
         return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
-            error: "Failed to build traefik config",
+            error: "Failed to build traefik config"
         });
     }
 }

+ 1 - 1
server/routers/traefik/index.ts

@@ -1 +1 @@
-export * from "./getTraefikConfig";
+export * from "./getTraefikConfig";

+ 5 - 2
server/routers/user/inviteUser.ts

@@ -23,7 +23,10 @@ const inviteUserParamsSchema = z
 
 const inviteUserBodySchema = z
     .object({
-        email: z.string().email(),
+        email: z
+            .string()
+            .email()
+            .transform((v) => v.toLowerCase()),
         roleId: z.number(),
         validHours: z.number().gt(0).lte(168),
         sendEmail: z.boolean().optional()
@@ -165,7 +168,7 @@ export async function inviteUser(
                 }),
                 {
                     to: email,
-                    from: config.getRawConfig().email?.no_reply,
+                    from: config.getNoReplyEmail(),
                     subject: "You're invited to join a Fossorial organization"
                 }
             );

+ 25 - 19
server/setup/migrations.ts

@@ -11,7 +11,7 @@ import m2 from "./scripts/1.0.0-beta2";
 import m3 from "./scripts/1.0.0-beta3";
 import m4 from "./scripts/1.0.0-beta5";
 import m5 from "./scripts/1.0.0-beta6";
-import { existsSync, mkdirSync } from "fs";
+import m6 from "./scripts/1.0.0-beta9";
 
 // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
 // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -22,7 +22,8 @@ const migrations = [
     { version: "1.0.0-beta.2", run: m2 },
     { version: "1.0.0-beta.3", run: m3 },
     { version: "1.0.0-beta.5", run: m4 },
-    { version: "1.0.0-beta.6", run: m5 }
+    { version: "1.0.0-beta.6", run: m5 },
+    { version: "1.0.0-beta.9", run: m6 }
     // Add new migrations here as they are created
 ] as const;
 
@@ -30,31 +31,36 @@ const migrations = [
 await runMigrations();
 
 export async function runMigrations() {
-    const appVersion = loadAppVersion();
-    if (!appVersion) {
-        throw new Error("APP_VERSION is not set in the environment");
-    }
-
-    if (exists) {
-        await executeScripts();
-    } else {
-        console.log("Running migrations...");
-        try {
-            migrate(db, {
-                migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
-            });
-            console.log("Migrations completed successfully.");
-        } catch (error) {
-            console.error("Error running migrations:", error);
+    try {
+        const appVersion = loadAppVersion();
+        if (!appVersion) {
+            throw new Error("APP_VERSION is not set in the environment");
         }
 
-        await db
+        if (exists) {
+            await executeScripts();
+        } else {
+            console.log("Running migrations...");
+            try {
+                migrate(db, {
+                    migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
+                });
+                console.log("Migrations completed successfully.");
+            } catch (error) {
+                console.error("Error running migrations:", error);
+            }
+
+            await db
             .insert(versionMigrations)
             .values({
                 version: appVersion,
                 executedAt: Date.now()
             })
             .execute();
+        }
+    } catch (e) {
+        console.error("Error running migrations:", e);
+        await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1));
     }
 }
 

+ 291 - 0
server/setup/scripts/1.0.0-beta9.ts

@@ -0,0 +1,291 @@
+import db from "@server/db";
+import {
+    emailVerificationCodes,
+    passwordResetTokens,
+    resourceOtp,
+    resources,
+    resourceWhitelist,
+    targets,
+    userInvites,
+    users
+} from "@server/db/schema";
+import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
+import { eq, sql } from "drizzle-orm";
+import fs from "fs";
+import yaml from "js-yaml";
+import path from "path";
+import { z } from "zod";
+import { fromZodError } from "zod-validation-error";
+
+export default async function migration() {
+    console.log("Running setup script 1.0.0-beta.9...");
+
+    // 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);
+
+    await db.transaction(async (trx) => {
+        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);
+
+            rawConfig.server.resource_session_request_param =
+                "p_session_request";
+            rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
+            delete rawConfig.server.resource_session_cookie_name;
+
+            if (!rawConfig.flags) {
+                rawConfig.flags = {};
+            }
+
+            rawConfig.flags.allow_raw_resources = true;
+
+            // Write the updated YAML back to the file
+            const updatedYaml = yaml.dump(rawConfig);
+            fs.writeFileSync(filePath, updatedYaml, "utf8");
+        } catch (e) {
+            console.log(
+                `Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
+            );
+            trx.rollback();
+            return;
+        }
+
+        try {
+            const traefikPath = path.join(
+                APP_PATH,
+                "traefik",
+                "traefik_config.yml"
+            );
+
+            // Define schema for traefik config validation
+            const schema = z.object({
+                entryPoints: z
+                    .object({
+                        websecure: z
+                            .object({
+                                address: z.string(),
+                                transport: z
+                                    .object({
+                                        respondingTimeouts: z.object({
+                                            readTimeout: z.string()
+                                        })
+                                    })
+                                    .optional()
+                            })
+                            .optional()
+                    })
+                    .optional(),
+                experimental: z.object({
+                    plugins: z.object({
+                        badger: z.object({
+                            moduleName: z.string(),
+                            version: z.string()
+                        })
+                    })
+                })
+            });
+
+            const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
+            const traefikConfig = yaml.load(traefikFileContents) as any;
+
+            let parsedConfig: any = schema.safeParse(traefikConfig);
+
+            if (parsedConfig.success) {
+                // Ensure websecure entrypoint exists
+                if (traefikConfig.entryPoints?.websecure) {
+                    // Add transport configuration
+                    traefikConfig.entryPoints.websecure.transport = {
+                        respondingTimeouts: {
+                            readTimeout: "30m"
+                        }
+                    };
+                }
+
+                traefikConfig.experimental.plugins.badger.version =
+                    "v1.0.0-beta.3";
+
+                const updatedTraefikYaml = yaml.dump(traefikConfig);
+                fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
+
+                console.log("Updated Badger version in Traefik config.");
+            } else {
+                console.log(fromZodError(parsedConfig.error));
+                console.log(
+                    "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
+                );
+            }
+        } catch (e) {
+            console.log(
+                "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
+            );
+            trx.rollback();
+            return;
+        }
+
+        try {
+            const traefikPath = path.join(
+                APP_PATH,
+                "traefik",
+                "dynamic_config.yml"
+            );
+
+            const schema = z.object({
+                http: z.object({
+                    middlewares: z.object({
+                        "redirect-to-https": z.object({
+                            redirectScheme: z.object({
+                                scheme: z.string(),
+                                permanent: z.boolean()
+                            })
+                        })
+                    })
+                })
+            });
+
+            const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
+            const traefikConfig = yaml.load(traefikFileContents) as any;
+
+            let parsedConfig: any = schema.safeParse(traefikConfig);
+
+            if (parsedConfig.success) {
+                // delete permanent from redirect-to-https middleware
+                delete traefikConfig.http.middlewares["redirect-to-https"].redirectScheme.permanent;
+
+                const updatedTraefikYaml = yaml.dump(traefikConfig);
+                fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
+
+                console.log("Deleted permanent from redirect-to-https middleware.");
+            } else {
+                console.log(fromZodError(parsedConfig.error));
+                console.log(
+                    "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually."
+                );
+            }
+        } catch (e) {
+            console.log(
+                "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended."
+            );
+        }
+
+        trx.run(sql`UPDATE ${users} SET email = LOWER(email);`);
+        trx.run(
+            sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);`
+        );
+        trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`);
+        trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`);
+        trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`);
+        trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`);
+
+        const resourcesAll = await trx
+            .select({
+                resourceId: resources.resourceId,
+                fullDomain: resources.fullDomain,
+                subdomain: resources.subdomain
+            })
+            .from(resources);
+
+        trx.run(`DROP INDEX resources_fullDomain_unique;`);
+        trx.run(`ALTER TABLE resources
+                DROP COLUMN fullDomain;
+            `);
+        trx.run(`ALTER TABLE resources
+                DROP COLUMN subdomain;
+            `);
+        trx.run(sql`ALTER TABLE resources
+                ADD COLUMN fullDomain TEXT;
+            `);
+        trx.run(sql`ALTER TABLE resources
+                ADD COLUMN subdomain TEXT;
+            `);
+        trx.run(sql`ALTER TABLE resources
+                ADD COLUMN http INTEGER DEFAULT true NOT NULL;
+            `);
+        trx.run(sql`ALTER TABLE resources
+                ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL;
+            `);
+        trx.run(sql`ALTER TABLE resources
+                ADD COLUMN proxyPort INTEGER;
+            `);
+
+        // write the new fullDomain and subdomain values back to the database
+        for (const resource of resourcesAll) {
+            await trx
+                .update(resources)
+                .set({
+                    fullDomain: resource.fullDomain,
+                    subdomain: resource.subdomain
+                })
+                .where(eq(resources.resourceId, resource.resourceId));
+        }
+
+        const targetsAll = await trx
+            .select({
+                targetId: targets.targetId,
+                method: targets.method
+            })
+            .from(targets);
+
+        trx.run(`ALTER TABLE targets
+                DROP COLUMN method;
+            `);
+        trx.run(`ALTER TABLE targets
+                DROP COLUMN protocol;
+            `);
+        trx.run(sql`ALTER TABLE targets
+                ADD COLUMN method TEXT;
+            `);
+
+        // write the new method and protocol values back to the database
+        for (const target of targetsAll) {
+            await trx
+                .update(targets)
+                .set({
+                    method: target.method
+                })
+                .where(eq(targets.targetId, target.targetId));
+        }
+
+        trx.run(
+            sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`
+        );
+        trx.run(
+            sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`
+        );
+    });
+
+    console.log("Done.");
+}

+ 8 - 8
src/app/[orgId]/settings/access/users/UsersTable.tsx

@@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
 import { useToast } from "@app/hooks/useToast";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
-import { formatAxiosError } from "@app/lib/api";;
+import { formatAxiosError } from "@app/lib/api";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
 import { useUserContext } from "@app/hooks/useUserContext";
@@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
                                             </Button>
                                         </DropdownMenuTrigger>
                                         <DropdownMenuContent align="end">
-                                            <DropdownMenuItem>
-                                                <Link
-                                                    href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
-                                                    className="block w-full"
-                                                >
+                                            <Link
+                                                href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
+                                                className="block w-full"
+                                            >
+                                                <DropdownMenuItem>
                                                     Manage User
-                                                </Link>
-                                            </DropdownMenuItem>
+                                                </DropdownMenuItem>
+                                            </Link>
                                             {userRow.email !== user?.email && (
                                                 <DropdownMenuItem
                                                     onClick={() => {

+ 223 - 43
src/app/[orgId]/settings/resources/CreateResourceForm.tsx

@@ -45,21 +45,64 @@ import {
 } from "@app/components/ui/command";
 import { CaretSortIcon } from "@radix-ui/react-icons";
 import CustomDomainInput from "./[resourceId]/CustomDomainInput";
-import { Axios, AxiosResponse } from "axios";
+import { AxiosResponse } from "axios";
 import { Resource } from "@server/db/schema";
 import { useOrgContext } from "@app/hooks/useOrgContext";
-import { subdomainSchema } from "@server/schemas/subdomainSchema";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
 import { cn } from "@app/lib/cn";
+import { Switch } from "@app/components/ui/switch";
+import {
+    Select,
+    SelectContent,
+    SelectItem,
+    SelectTrigger,
+    SelectValue
+} from "@app/components/ui/select";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
+import Link from "next/link";
+import { SquareArrowOutUpRight } from "lucide-react";
 
-const accountFormSchema = z.object({
-    subdomain: subdomainSchema,
-    name: z.string(),
-    siteId: z.number()
-});
+const createResourceFormSchema = z
+    .object({
+        subdomain: z.string().optional(),
+        name: z.string().min(1).max(255),
+        siteId: z.number(),
+        http: z.boolean(),
+        protocol: z.string(),
+        proxyPort: z.number().optional()
+    })
+    .refine(
+        (data) => {
+            if (!data.http) {
+                return z
+                    .number()
+                    .int()
+                    .min(1)
+                    .max(65535)
+                    .safeParse(data.proxyPort).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid port number",
+            path: ["proxyPort"]
+        }
+    )
+    .refine(
+        (data) => {
+            if (data.http) {
+                return subdomainSchema.safeParse(data.subdomain).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid subdomain",
+            path: ["subdomain"]
+        }
+    );
 
-type AccountFormValues = z.infer<typeof accountFormSchema>;
+type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
 
 type CreateResourceFormProps = {
     open: boolean;
@@ -81,15 +124,18 @@ export default function CreateResourceForm({
     const router = useRouter();
 
     const { org } = useOrgContext();
+    const { env } = useEnvContext();
 
     const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
     const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
 
-    const form = useForm<AccountFormValues>({
-        resolver: zodResolver(accountFormSchema),
+    const form = useForm<CreateResourceFormValues>({
+        resolver: zodResolver(createResourceFormSchema),
         defaultValues: {
             subdomain: "",
-            name: "My Resource"
+            name: "My Resource",
+            http: true,
+            protocol: "tcp"
         }
     });
 
@@ -112,7 +158,7 @@ export default function CreateResourceForm({
         fetchSites();
     }, [open]);
 
-    async function onSubmit(data: AccountFormValues) {
+    async function onSubmit(data: CreateResourceFormValues) {
         console.log(data);
 
         const res = await api
@@ -120,8 +166,11 @@ export default function CreateResourceForm({
                 `/org/${orgId}/site/${data.siteId}/resource/`,
                 {
                     name: data.name,
-                    subdomain: data.subdomain
-                    // subdomain: data.subdomain,
+                    subdomain: data.http ? data.subdomain : undefined,
+                    http: data.http,
+                    protocol: data.protocol,
+                    proxyPort: data.http ? undefined : data.proxyPort,
+                    siteId: data.siteId
                 }
             )
             .catch((e) => {
@@ -188,34 +237,165 @@ export default function CreateResourceForm({
                                         </FormItem>
                                     )}
                                 />
-                                <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 fully qualified
-                                                domain name that will be used to
-                                                access the resource.
-                                            </FormDescription>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
+
+                                {!env.flags.allowRawResources || (
+                                    <FormField
+                                        control={form.control}
+                                        name="http"
+                                        render={({ field }) => (
+                                            <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+                                                <div className="space-y-0.5">
+                                                    <FormLabel className="text-base">
+                                                        HTTP Resource
+                                                    </FormLabel>
+                                                    <FormDescription>
+                                                        Toggle if this is an
+                                                        HTTP resource or a raw
+                                                        TCP/UDP resource
+                                                    </FormDescription>
+                                                </div>
+                                                <FormControl>
+                                                    <Switch
+                                                        checked={field.value}
+                                                        onCheckedChange={
+                                                            field.onChange
+                                                        }
+                                                    />
+                                                </FormControl>
+                                            </FormItem>
+                                        )}
+                                    />
+                                )}
+
+                                {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",
+                                                                value
+                                                            )
+                                                        }
+                                                    />
+                                                </FormControl>
+                                                <FormDescription>
+                                                    This is the fully qualified
+                                                    domain name that will be
+                                                    used to access the resource.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                )}
+
+                                {!form.watch("http") && (
+                                    <Link
+                                        className="text-sm text-primary flex items-center gap-1"
+                                        href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
+                                        target="_blank"
+                                        rel="noopener noreferrer"
+                                    >
+                                        <span>
+                                            Learn how to configure TCP/UDP resources
+                                        </span>
+                                        <SquareArrowOutUpRight size={14} />
+                                    </Link>
+                                )}
+
+                                {!form.watch("http") && (
+                                    <>
+                                        <FormField
+                                            control={form.control}
+                                            name="protocol"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Protocol
+                                                    </FormLabel>
+                                                    <Select
+                                                        value={field.value}
+                                                        onValueChange={
+                                                            field.onChange
+                                                        }
+                                                    >
+                                                        <FormControl>
+                                                            <SelectTrigger>
+                                                                <SelectValue placeholder="Select a protocol" />
+                                                            </SelectTrigger>
+                                                        </FormControl>
+                                                        <SelectContent>
+                                                            <SelectItem value="tcp">
+                                                                TCP
+                                                            </SelectItem>
+                                                            <SelectItem value="udp">
+                                                                UDP
+                                                            </SelectItem>
+                                                        </SelectContent>
+                                                    </Select>
+                                                    <FormDescription>
+                                                        The protocol to use for
+                                                        the resource
+                                                    </FormDescription>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                        <FormField
+                                            control={form.control}
+                                            name="proxyPort"
+                                            render={({ field }) => (
+                                                <FormItem>
+                                                    <FormLabel>
+                                                        Port Number
+                                                    </FormLabel>
+                                                    <FormControl>
+                                                        <Input
+                                                            type="number"
+                                                            placeholder="Enter port number"
+                                                            value={
+                                                                field.value ??
+                                                                ""
+                                                            }
+                                                            onChange={(e) =>
+                                                                field.onChange(
+                                                                    e.target
+                                                                        .value
+                                                                        ? parseInt(
+                                                                              e
+                                                                                  .target
+                                                                                  .value
+                                                                          )
+                                                                        : null
+                                                                )
+                                                            }
+                                                        />
+                                                    </FormControl>
+                                                    <FormDescription>
+                                                        The port number to proxy
+                                                        requests to (required
+                                                        for non-HTTP resources)
+                                                    </FormDescription>
+                                                    <FormMessage />
+                                                </FormItem>
+                                            )}
+                                        />
+                                    </>
+                                )}
+
                                 <FormField
                                     control={form.control}
                                     name="siteId"
@@ -257,7 +437,7 @@ export default function CreateResourceForm({
                                                                     (site) => (
                                                                         <CommandItem
                                                                             value={
-                                                                                site.name
+                                                                                site.niceId
                                                                             }
                                                                             key={
                                                                                 site.siteId

+ 52 - 27
src/app/[orgId]/settings/resources/ResourcesTable.tsx

@@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm";
 import { useState } from "react";
 import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
 import { set } from "zod";
-import { formatAxiosError } from "@app/lib/api";;
+import { formatAxiosError } from "@app/lib/api";
 import { useToast } from "@app/hooks/useToast";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -39,6 +39,9 @@ export type ResourceRow = {
     site: string;
     siteId: string;
     hasAuth: boolean;
+    http: boolean;
+    protocol: string;
+    proxyPort: number | null;
 };
 
 type ResourcesTableProps = {
@@ -91,14 +94,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                             </Button>
                         </DropdownMenuTrigger>
                         <DropdownMenuContent align="end">
-                            <DropdownMenuItem>
-                                <Link
-                                    className="block w-full"
-                                    href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
-                                >
+                            <Link
+                                className="block w-full"
+                                href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
+                            >
+                                <DropdownMenuItem>
                                     View settings
-                                </Link>
-                            </DropdownMenuItem>
+                                </DropdownMenuItem>
+                            </Link>
                             <DropdownMenuItem
                                 onClick={() => {
                                     setSelectedResource(resourceRow);
@@ -146,24 +149,40 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
             cell: ({ row }) => {
                 const resourceRow = row.original;
                 return (
-                    <Button variant="outline">
-                        <Link
-                            href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
-                        >
+                    <Link
+                        href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
+                    >
+                        <Button variant="outline">
                             {resourceRow.site}
-                        </Link>
-                        <ArrowUpRight className="ml-2 h-4 w-4" />
-                    </Button>
+                            <ArrowUpRight className="ml-2 h-4 w-4" />
+                        </Button>
+                    </Link>
+                );
+            }
+        },
+        {
+            accessorKey: "protocol",
+            header: "Protocol",
+            cell: ({ row }) => {
+                const resourceRow = row.original;
+                return (
+                    <span>{resourceRow.protocol.toUpperCase()}</span>
                 );
             }
         },
         {
             accessorKey: "domain",
-            header: "Full URL",
+            header: "Access",
             cell: ({ row }) => {
                 const resourceRow = row.original;
                 return (
+                    <div>
+                    {!resourceRow.http ? (
+                                            <CopyToClipboard text={resourceRow.proxyPort!.toString()} isLink={false} />
+                    ) : (
                     <CopyToClipboard text={resourceRow.domain} isLink={true} />
+                    )}
+                    </div>
                 );
             }
         },
@@ -186,17 +205,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                 const resourceRow = row.original;
                 return (
                     <div>
-                        {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>
-                        )}
+                        
+
+                        {!resourceRow.http ? (
+                            <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>
                 );
             }

+ 59 - 35
src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx

@@ -2,12 +2,8 @@
 
 import { useState } from "react";
 import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
 import {
     InfoIcon,
-    LinkIcon,
-    CheckIcon,
-    CopyIcon,
     ShieldCheck,
     ShieldOff
 } from "lucide-react";
@@ -42,37 +38,65 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
             </AlertTitle>
             <AlertDescription className="mt-4">
                 <InfoSections>
-                    <InfoSection>
-                        <InfoSectionTitle>Authentication</InfoSectionTitle>
-                        <InfoSectionContent>
-                            {authInfo.password ||
-                            authInfo.pincode ||
-                            authInfo.sso ||
-                            authInfo.whitelist ? (
-                                <div className="flex items-start space-x-2 text-green-500">
-                                    <ShieldCheck className="w-4 h-4 mt-0.5" />
-                                    <span>
-                                        This resource is protected with at least
-                                        one auth method.
-                                    </span>
-                                </div>
-                            ) : (
-                                <div className="flex items-center space-x-2 text-yellow-500">
-                                    <ShieldOff className="w-4 h-4" />
-                                    <span>
-                                        Anyone can access this resource.
-                                    </span>
-                                </div>
-                            )}
-                        </InfoSectionContent>
-                    </InfoSection>
-                    <Separator orientation="vertical" />
-                    <InfoSection>
-                        <InfoSectionTitle>URL</InfoSectionTitle>
-                        <InfoSectionContent>
-                            <CopyToClipboard text={fullUrl} isLink={true} />
-                        </InfoSectionContent>
-                    </InfoSection>
+                    {resource.http ? (
+                        <>
+                            <InfoSection>
+                                <InfoSectionTitle>
+                                    Authentication
+                                </InfoSectionTitle>
+                                <InfoSectionContent>
+                                    {authInfo.password ||
+                                    authInfo.pincode ||
+                                    authInfo.sso ||
+                                    authInfo.whitelist ? (
+                                        <div className="flex items-start space-x-2 text-green-500">
+                                            <ShieldCheck className="w-4 h-4 mt-0.5" />
+                                            <span>
+                                                This resource is protected with
+                                                at least one auth method.
+                                            </span>
+                                        </div>
+                                    ) : (
+                                        <div className="flex items-center space-x-2 text-yellow-500">
+                                            <ShieldOff className="w-4 h-4" />
+                                            <span>
+                                                Anyone can access this resource.
+                                            </span>
+                                        </div>
+                                    )}
+                                </InfoSectionContent>
+                            </InfoSection>
+                            <Separator orientation="vertical" />
+                            <InfoSection>
+                                <InfoSectionTitle>URL</InfoSectionTitle>
+                                <InfoSectionContent>
+                                    <CopyToClipboard
+                                        text={fullUrl}
+                                        isLink={true}
+                                    />
+                                </InfoSectionContent>
+                            </InfoSection>
+                        </>
+                    ) : (
+                        <>
+                            <InfoSection>
+                                <InfoSectionTitle>Protocol</InfoSectionTitle>
+                                <InfoSectionContent>
+                                    <span>{resource.protocol.toUpperCase()}</span>
+                                </InfoSectionContent>
+                            </InfoSection>
+                            <Separator orientation="vertical" />
+                            <InfoSection>
+                                <InfoSectionTitle>Port</InfoSectionTitle>
+                                <InfoSectionContent>
+                                    <CopyToClipboard
+                                        text={resource.proxyPort!.toString()}
+                                        isLink={false}
+                                    />
+                                </InfoSectionContent>
+                            </InfoSection>
+                        </>
+                    )}
                 </InfoSections>
             </AlertDescription>
         </Alert>

+ 16 - 2
src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx

@@ -48,6 +48,7 @@ import {
     SettingsSectionFooter
 } from "@app/components/Settings";
 import { SwitchInput } from "@app/components/SwitchInput";
+import { InfoPopup } from "@app/components/ui/info-popup";
 
 const UsersRolesFormSchema = z.object({
     roles: z.array(
@@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() {
                                             render={({ field }) => (
                                                 <FormItem>
                                                     <FormLabel>
-                                                        Whitelisted Emails
+                                                        <InfoPopup
+                                                            text="Whitelisted Emails"
+                                                            info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
+                                                        />
                                                     </FormLabel>
                                                     <FormControl>
-                                                        {/* @ts-ignore */}
                                                         {/* @ts-ignore */}
                                                         <TagInput
                                                             {...field}
@@ -681,6 +684,17 @@ export default function ResourceAuthenticationPage() {
                                                                 return z
                                                                     .string()
                                                                     .email()
+                                                                    .or(
+                                                                        z
+                                                                            .string()
+                                                                            .regex(
+                                                                                /^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
+                                                                                {
+                                                                                    message:
+                                                                                        "Invalid email address. Wildcard (*) must be the entire local part."
+                                                                                }
+                                                                            )
+                                                                    )
                                                                     .safeParse(
                                                                         tag
                                                                     ).success;

+ 98 - 80
src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx

@@ -63,6 +63,7 @@ import {
 } from "@app/components/Settings";
 import { SwitchInput } from "@app/components/SwitchInput";
 import { useSiteContext } from "@app/hooks/useSiteContext";
+import { InfoPopup } from "@app/components/ui/info-popup";
 
 // Regular expressions for validation
 const DOMAIN_REGEX =
@@ -94,7 +95,7 @@ const domainSchema = z
 
 const addTargetSchema = z.object({
     ip: domainSchema,
-    method: z.string(),
+    method: z.string().nullable(),
     port: z.coerce.number().int().positive()
     // protocol: z.string(),
 });
@@ -129,9 +130,9 @@ export default function ReverseProxyTargets(props: {
     const addTargetForm = useForm({
         resolver: zodResolver(addTargetSchema),
         defaultValues: {
-            ip: "",
-            method: "http",
-            port: 80
+            ip: "localhost",
+            method: resource.http ? "http" : null,
+            port: resource.http ? 80 : resource.proxyPort || 1234
             // protocol: "TCP",
         }
     });
@@ -321,7 +322,7 @@ export default function ReverseProxyTargets(props: {
         });
 
         setSslEnabled(val);
-        updateResource({ ssl: sslEnabled });
+        updateResource({ ssl: val });
 
         toast({
             title: "SSL Configuration",
@@ -330,26 +331,6 @@ export default function ReverseProxyTargets(props: {
     }
 
     const columns: ColumnDef<LocalTarget>[] = [
-        {
-            accessorKey: "method",
-            header: "Method",
-            cell: ({ row }) => (
-                <Select
-                    defaultValue={row.original.method}
-                    onValueChange={(value) =>
-                        updateTarget(row.original.targetId, { method: value })
-                    }
-                >
-                    <SelectTrigger className="min-w-[100px]">
-                        {row.original.method}
-                    </SelectTrigger>
-                    <SelectContent>
-                        <SelectItem value="http">http</SelectItem>
-                        <SelectItem value="https">https</SelectItem>
-                    </SelectContent>
-                </Select>
-            )
-        },
         {
             accessorKey: "ip",
             header: "IP / Hostname",
@@ -436,6 +417,32 @@ export default function ReverseProxyTargets(props: {
         }
     ];
 
+    if (resource.http) {
+        const methodCol: ColumnDef<LocalTarget> = {
+            accessorKey: "method",
+            header: "Method",
+            cell: ({ row }) => (
+                <Select
+                    defaultValue={row.original.method ?? ""}
+                    onValueChange={(value) =>
+                        updateTarget(row.original.targetId, { method: value })
+                    }
+                >
+                    <SelectTrigger className="min-w-[100px]">
+                        {row.original.method}
+                    </SelectTrigger>
+                    <SelectContent>
+                        <SelectItem value="http">http</SelectItem>
+                        <SelectItem value="https">https</SelectItem>
+                    </SelectContent>
+                </Select>
+            )
+        };
+
+        // add this to the first column
+        columns.unshift(methodCol);
+    }
+
     const table = useReactTable({
         data: targets,
         columns,
@@ -451,29 +458,29 @@ export default function ReverseProxyTargets(props: {
 
     return (
         <SettingsContainer>
-            {/* SSL Section */}
-            <SettingsSection>
-                <SettingsSectionHeader>
-                    <SettingsSectionTitle>
-                        SSL Configuration
-                    </SettingsSectionTitle>
-                    <SettingsSectionDescription>
-                        Setup SSL to secure your connections with LetsEncrypt
-                        certificates
-                    </SettingsSectionDescription>
-                </SettingsSectionHeader>
-                <SettingsSectionBody>
-                    <SwitchInput
-                        id="ssl-toggle"
-                        label="Enable SSL (https)"
-                        defaultChecked={resource.ssl}
-                        onCheckedChange={async (val) => {
-                            await saveSsl(val);
-                        }}
-                    />
-                </SettingsSectionBody>
-            </SettingsSection>
-
+            {resource.http && (
+                <SettingsSection>
+                    <SettingsSectionHeader>
+                        <SettingsSectionTitle>
+                            SSL Configuration
+                        </SettingsSectionTitle>
+                        <SettingsSectionDescription>
+                            Setup SSL to secure your connections with
+                            LetsEncrypt certificates
+                        </SettingsSectionDescription>
+                    </SettingsSectionHeader>
+                    <SettingsSectionBody>
+                        <SwitchInput
+                            id="ssl-toggle"
+                            label="Enable SSL (https)"
+                            defaultChecked={resource.ssl}
+                            onCheckedChange={async (val) => {
+                                await saveSsl(val);
+                            }}
+                        />
+                    </SettingsSectionBody>
+                </SettingsSection>
+            )}
             {/* Targets Section */}
             <SettingsSection>
                 <SettingsSectionHeader>
@@ -491,39 +498,47 @@ export default function ReverseProxyTargets(props: {
                             className="space-y-4"
                         >
                             <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
-                                <FormField
-                                    control={addTargetForm.control}
-                                    name="method"
-                                    render={({ field }) => (
-                                        <FormItem>
-                                            <FormLabel>Method</FormLabel>
-                                            <FormControl>
-                                                <Select
-                                                    {...field}
-                                                    onValueChange={(value) => {
-                                                        addTargetForm.setValue(
-                                                            "method",
+                                {resource.http && (
+                                    <FormField
+                                        control={addTargetForm.control}
+                                        name="method"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>Method</FormLabel>
+                                                <FormControl>
+                                                    <Select
+                                                        value={
+                                                            field.value ||
+                                                            undefined
+                                                        }
+                                                        onValueChange={(
                                                             value
-                                                        );
-                                                    }}
-                                                >
-                                                    <SelectTrigger id="method">
-                                                        <SelectValue placeholder="Select method" />
-                                                    </SelectTrigger>
-                                                    <SelectContent>
-                                                        <SelectItem value="http">
-                                                            http
-                                                        </SelectItem>
-                                                        <SelectItem value="https">
-                                                            https
-                                                        </SelectItem>
-                                                    </SelectContent>
-                                                </Select>
-                                            </FormControl>
-                                            <FormMessage />
-                                        </FormItem>
-                                    )}
-                                />
+                                                        ) => {
+                                                            addTargetForm.setValue(
+                                                                "method",
+                                                                value
+                                                            );
+                                                        }}
+                                                    >
+                                                        <SelectTrigger id="method">
+                                                            <SelectValue placeholder="Select method" />
+                                                        </SelectTrigger>
+                                                        <SelectContent>
+                                                            <SelectItem value="http">
+                                                                http
+                                                            </SelectItem>
+                                                            <SelectItem value="https">
+                                                                https
+                                                            </SelectItem>
+                                                        </SelectContent>
+                                                    </Select>
+                                                </FormControl>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                )}
+
                                 <FormField
                                     control={addTargetForm.control}
                                     name="ip"
@@ -637,6 +652,9 @@ export default function ReverseProxyTargets(props: {
                             </TableBody>
                         </Table>
                     </TableContainer>
+                    <p className="text-sm text-muted-foreground">
+                        Adding more than one target above will enable load balancing.
+                    </p>
                 </SettingsSectionBody>
                 <SettingsSectionFooter>
                     <Button

+ 112 - 51
src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx

@@ -13,22 +13,7 @@ import {
     FormLabel,
     FormMessage
 } from "@/components/ui/form";
-import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
 import { Input } from "@/components/ui/input";
-import {
-    Command,
-    CommandEmpty,
-    CommandGroup,
-    CommandInput,
-    CommandItem,
-    CommandList
-} from "@/components/ui/command";
-
-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";
@@ -49,16 +34,46 @@ import {
 } from "@app/components/Settings";
 import { useOrgContext } from "@app/hooks/useOrgContext";
 import CustomDomainInput from "../CustomDomainInput";
-import ResourceInfoBox from "../ResourceInfoBox";
-import { subdomainSchema } from "@server/schemas/subdomainSchema";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
+import { subdomainSchema } from "@server/schemas/subdomainSchema";
 
-const GeneralFormSchema = z.object({
-    name: z.string(),
-    subdomain: subdomainSchema
-    // siteId: z.number(),
-});
+const GeneralFormSchema = z
+    .object({
+        subdomain: z.string().optional(),
+        name: z.string().min(1).max(255),
+        proxyPort: z.number().optional(),
+        http: z.boolean()
+    })
+    .refine(
+        (data) => {
+            if (!data.http) {
+                return z
+                    .number()
+                    .int()
+                    .min(1)
+                    .max(65535)
+                    .safeParse(data.proxyPort).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid port number",
+            path: ["proxyPort"]
+        }
+    )
+    .refine(
+        (data) => {
+            if (data.http) {
+                return subdomainSchema.safeParse(data.subdomain).success;
+            }
+            return true;
+        },
+        {
+            message: "Invalid subdomain",
+            path: ["subdomain"]
+        }
+    );
 
 type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
 
@@ -81,8 +96,9 @@ export default function GeneralForm() {
         resolver: zodResolver(GeneralFormSchema),
         defaultValues: {
             name: resource.name,
-            subdomain: resource.subdomain
-            // siteId: resource.siteId!,
+            subdomain: resource.subdomain ? resource.subdomain : undefined,
+            proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
+            http: resource.http
         },
         mode: "onChange"
     });
@@ -169,33 +185,78 @@ export default function GeneralForm() {
                                     )}
                                 />
 
-                                <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 ? (
+                                    <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>
+                                        )}
+                                    />
+                                ) : (
+                                    <FormField
+                                        control={form.control}
+                                        name="proxyPort"
+                                        render={({ field }) => (
+                                            <FormItem>
+                                                <FormLabel>
+                                                    Port Number
+                                                </FormLabel>
+                                                <FormControl>
+                                                    <Input
+                                                        type="number"
+                                                        placeholder="Enter port number"
+                                                        value={
+                                                            field.value ?? ""
+                                                        }
+                                                        onChange={(e) =>
+                                                            field.onChange(
+                                                                e.target.value
+                                                                    ? parseInt(
+                                                                          e
+                                                                              .target
+                                                                              .value
+                                                                      )
+                                                                    : null
+                                                            )
+                                                        }
+                                                    />
+                                                </FormControl>
+                                                <FormDescription>
+                                                    This is the port that will
+                                                    be used to access the
+                                                    resource.
+                                                </FormDescription>
+                                                <FormMessage />
+                                            </FormItem>
+                                        )}
+                                    />
+                                )}
                             </form>
                         </Form>
                     </SettingsSectionForm>

+ 7 - 4
src/app/[orgId]/settings/resources/[resourceId]/layout.tsx

@@ -90,13 +90,16 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
             title: "Connectivity",
             href: `/{orgId}/settings/resources/{resourceId}/connectivity`
             // icon: <Cloud className="w-4 h-4" />,
-        },
-        {
+        }
+    ];
+
+    if (resource.http) {
+        sidebarNavItems.push({
             title: "Authentication",
             href: `/{orgId}/settings/resources/{resourceId}/authentication`
             // icon: <Shield className="w-4 h-4" />,
-        }
-    ];
+        });
+    }
 
     return (
         <>

+ 3 - 0
src/app/[orgId]/settings/resources/page.tsx

@@ -53,6 +53,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
             domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
             site: resource.siteName || "None",
             siteId: resource.siteId || "Unknown",
+            protocol: resource.protocol,
+            proxyPort: resource.proxyPort,
+            http: resource.http,
             hasAuth:
                 resource.sso ||
                 resource.pincodeId !== null ||

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

@@ -153,7 +153,9 @@ export default function CreateShareLinkForm({
 
             if (res?.status === 200) {
                 setResources(
-                    res.data.data.resources.map((r) => ({
+                    res.data.data.resources.filter((r) => {
+                        return r.http;
+                    }).map((r) => ({
                         resourceId: r.resourceId,
                         name: r.name,
                         resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
@@ -318,7 +320,7 @@ export default function CreateShareLinkForm({
                                                                             ) => (
                                                                                 <CommandItem
                                                                                     value={
-                                                                                        r.name
+                                                                                        r.resourceId.toString()
                                                                                     }
                                                                                     key={
                                                                                         r.resourceId

+ 5 - 7
src/app/[orgId]/settings/share-links/ShareLinksTable.tsx

@@ -145,14 +145,12 @@ export default function ShareLinksTable({
             cell: ({ row }) => {
                 const r = row.original;
                 return (
-                    <Button variant="outline">
-                        <Link
-                            href={`/${orgId}/settings/resources/${r.resourceId}`}
-                        >
+                    <Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
+                        <Button variant="outline">
                             {r.resourceName}
-                        </Link>
-                        <ArrowUpRight className="ml-2 h-4 w-4" />
-                    </Button>
+                            <ArrowUpRight className="ml-2 h-4 w-4" />
+                        </Button>
+                    </Link>
                 );
             }
         },

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

@@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
                             </Button>
                         </DropdownMenuTrigger>
                         <DropdownMenuContent align="end">
-                            <DropdownMenuItem>
-                                <Link
-                                    className="block w-full"
-                                    href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
-                                >
+                            <Link
+                                className="block w-full"
+                                href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
+                            >
+                                <DropdownMenuItem>
                                     View settings
-                                </Link>
-                            </DropdownMenuItem>
+                                </DropdownMenuItem>
+                            </Link>
                             <DropdownMenuItem
                                 onClick={() => {
                                     setSelectedSite(siteRow);

+ 15 - 4
src/app/auth/resource/[resourceId]/AccessToken.tsx

@@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button";
 import {
     Card,
     CardContent,
-    CardFooter,
     CardHeader,
     CardTitle
 } from "@app/components/ui/card";
 import { useEnvContext } from "@app/hooks/useEnvContext";
 import { AuthWithAccessTokenResponse } from "@server/routers/resource";
 import { AxiosResponse } from "axios";
-import { Loader2 } from "lucide-react";
 import Link from "next/link";
 import { useEffect, useState } from "react";
 
@@ -32,7 +30,17 @@ export default function AccessToken({
     const [loading, setLoading] = useState(true);
     const [isValid, setIsValid] = useState(false);
 
-    const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
+    const api = createApiClient({ env });
+
+    function appendRequestToken(url: string, token: string) {
+        const fullUrl = new URL(url);
+        fullUrl.searchParams.append(
+            env.server.resourceSessionRequestParam,
+            token
+        );
+        return fullUrl.toString();
+    }
 
     useEffect(() => {
         if (!accessTokenId || !accessToken) {
@@ -51,7 +59,10 @@ export default function AccessToken({
 
                 if (res.data.data.session) {
                     setIsValid(true);
-                    window.location.href = redirectUrl;
+                    window.location.href = appendRequestToken(
+                        redirectUrl,
+                        res.data.data.session
+                    );
                 }
             } catch (e) {
                 console.error("Error checking access token", e);

+ 1 - 1
src/app/auth/resource/[resourceId]/ResourceAccessDenied.tsx

@@ -19,7 +19,7 @@ export default function ResourceAccessDenied() {
                 </CardTitle>
             </CardHeader>
             <CardContent>
-                You're not alowed to access this resource. If this is a mistake,
+                You're not allowed to access this resource. If this is a mistake,
                 please contact the administrator.
                 <div className="text-center mt-4">
                     <Button>

+ 16 - 21
src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx

@@ -1,6 +1,6 @@
 "use client";
 
-import { useEffect, useState, useSyncExternalStore } from "react";
+import { useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { useForm } from "react-hook-form";
 import * as z from "zod";
@@ -8,7 +8,6 @@ import {
     Card,
     CardContent,
     CardDescription,
-    CardFooter,
     CardHeader,
     CardTitle
 } from "@/components/ui/card";
@@ -30,9 +29,6 @@ import {
     Key,
     User,
     Send,
-    ArrowLeft,
-    ArrowRight,
-    Lock,
     AtSign
 } from "lucide-react";
 import {
@@ -47,10 +43,8 @@ import { AxiosResponse } from "axios";
 import LoginForm from "@app/components/LoginForm";
 import {
     AuthWithPasswordResponse,
-    AuthWithAccessTokenResponse,
     AuthWithWhitelistResponse
 } from "@server/routers/resource";
-import { redirect } from "next/dist/server/api-utils";
 import ResourceAccessDenied from "./ResourceAccessDenied";
 import { createApiClient } from "@app/lib/api";
 import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
 
     const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
 
-    const api = createApiClient(useEnvContext());
+    const { env } = useEnvContext();
+
+    const api = createApiClient({ env });
 
     function getDefaultSelectedMethod() {
         if (props.methods.sso) {
@@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
         }
     });
 
+    function appendRequestToken(url: string, token: string) {
+        const fullUrl = new URL(url);
+        fullUrl.searchParams.append(
+            env.server.resourceSessionRequestParam,
+            token
+        );
+        return fullUrl.toString();
+    }
+
     const onWhitelistSubmit = (values: any) => {
         setLoadingLogin(true);
         api.post<AxiosResponse<AuthWithWhitelistResponse>>(
@@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
 
                 const session = res.data.data.session;
                 if (session) {
-                    window.location.href = props.redirect;
+                    window.location.href = appendRequestToken(props.redirect, session);
                 }
             })
             .catch((e) => {
@@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
                 setPincodeError(null);
                 const session = res.data.data.session;
                 if (session) {
-                    window.location.href = props.redirect;
+                    window.location.href = appendRequestToken(props.redirect, session);
                 }
             })
             .catch((e) => {
@@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
                 setPasswordError(null);
                 const session = res.data.data.session;
                 if (session) {
-                    window.location.href = props.redirect;
+                    window.location.href = appendRequestToken(props.redirect, session);
                 }
             })
             .catch((e) => {
@@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
                             </Tabs>
                         </CardContent>
                     </Card>
-                    {/* {activeTab === "sso" && (
-                        <div className="flex justify-center mt-4">
-                            <p className="text-sm text-muted-foreground">
-                                Don't have an account?{" "}
-                                <a href="#" className="underline">
-                                    Sign up
-                                </a>
-                            </p>
-                        </div>
-                    )} */}
                 </div>
             ) : (
                 <ResourceAccessDenied />

+ 22 - 34
src/app/auth/resource/[resourceId]/page.tsx

@@ -1,7 +1,6 @@
 import {
-    AuthWithAccessTokenResponse,
     GetResourceAuthInfoResponse,
-    GetResourceResponse
+    GetExchangeTokenResponse
 } from "@server/routers/resource";
 import ResourceAuthPortal from "./ResourceAuthPortal";
 import { internal, priv } from "@app/lib/api";
@@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession";
 import { redirect } from "next/navigation";
 import ResourceNotFound from "./ResourceNotFound";
 import ResourceAccessDenied from "./ResourceAccessDenied";
-import { cookies } from "next/headers";
-import { CheckResourceSessionResponse } from "@server/routers/auth";
-import AccessTokenInvalid from "./AccessToken";
 import AccessToken from "./AccessToken";
 import { pullEnv } from "@app/lib/pullEnv";
 
@@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: {
         // TODO: fix this
         return (
             <div className="w-full max-w-md">
-            {/* @ts-ignore */}
+                {/* @ts-ignore */}
                 <ResourceNotFound />
             </div>
         );
@@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: {
         );
     }
 
-    const allCookies = await cookies();
-    const cookieName =
-        env.server.resourceSessionCookieName + `_${params.resourceId}`;
-    const sessionId = allCookies.get(cookieName)?.value ?? null;
-
-    if (sessionId) {
-        let doRedirect = false;
-        try {
-            const res = await priv.get<
-                AxiosResponse<CheckResourceSessionResponse>
-            >(`/resource-session/${params.resourceId}/${sessionId}`);
-
-            if (res && res.data.data.valid) {
-                doRedirect = true;
-            }
-        } catch (e) {}
-
-        if (doRedirect) {
-            redirect(redirectUrl);
-        }
-    }
-
     if (!hasAuth) {
         // no authentication so always go straight to the resource
         redirect(redirectUrl);
     }
 
+
+    // convert the dashboard token into a resource session token
     let userIsUnauthorized = false;
     if (user && authInfo.sso) {
-        let doRedirect = false;
+        let redirectToUrl: string | undefined;
         try {
-            const res = await internal.get<AxiosResponse<GetResourceResponse>>(
-                `/resource/${params.resourceId}`,
+            const res = await priv.post<
+                AxiosResponse<GetExchangeTokenResponse>
+            >(
+                `/resource/${params.resourceId}/get-exchange-token`,
+                {},
                 await authCookieHeader()
             );
 
-            doRedirect = true;
+            if (res.data.data.requestToken) {
+                const paramName = env.server.resourceSessionRequestParam;
+                // append the param with the token to the redirect url
+                const fullUrl = new URL(redirectUrl);
+                fullUrl.searchParams.append(
+                    paramName,
+                    res.data.data.requestToken
+                );
+                redirectToUrl = fullUrl.toString();
+            }
         } catch (e) {
             userIsUnauthorized = true;
         }
 
-        if (doRedirect) {
-            redirect(redirectUrl);
+        if (redirectToUrl) {
+            redirect(redirectToUrl);
         }
     }
 

+ 38 - 0
src/components/ui/info-popup.tsx

@@ -0,0 +1,38 @@
+"use client";
+
+import React from "react";
+import { Info } from "lucide-react";
+import {
+    Popover,
+    PopoverContent,
+    PopoverTrigger
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+
+interface InfoPopupProps {
+    text: string;
+    info: string;
+}
+
+export function InfoPopup({ text, info }: InfoPopupProps) {
+    return (
+        <div className="flex items-center space-x-2">
+            <span>{text}</span>
+            <Popover>
+                <PopoverTrigger asChild>
+                    <Button
+                        variant="ghost"
+                        size="icon"
+                        className="h-6 w-6 rounded-full p-0"
+                    >
+                        <Info className="h-4 w-4" />
+                        <span className="sr-only">Show info</span>
+                    </Button>
+                </PopoverTrigger>
+                <PopoverContent className="w-80">
+                    <p className="text-sm text-muted-foreground">{info}</p>
+                </PopoverContent>
+            </Popover>
+        </div>
+    );
+}

+ 5 - 3
src/lib/pullEnv.ts

@@ -6,8 +6,8 @@ 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,
-            resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string,
-            resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_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,
@@ -26,7 +26,9 @@ export function pullEnv(): Env {
             emailVerificationRequired:
                 process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
                     ? true
-                    : false
+                    : false,
+            allowRawResources:
+                process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
         }
     };
 }

+ 2 - 1
src/lib/types/env.ts

@@ -7,8 +7,8 @@ export type Env = {
         externalPort: string;
         nextPort: string;
         sessionCookieName: string;
-        resourceSessionCookieName: string;
         resourceAccessTokenParam: string;
+        resourceSessionRequestParam: string;
     },
     email: {
         emailEnabled: boolean;
@@ -17,5 +17,6 @@ export type Env = {
         disableSignupWithoutInvite: boolean;
         disableUserCreateOrg: boolean;
         emailVerificationRequired: boolean;
+        allowRawResources: boolean;
     }
 };