浏览代码

Squashed commit of the following:

commit c276d2193da5dbe7af5197bdf7e2bcce6f87b0cf
Author: Owen Schwartz <owen@txv.io>
Date:   Tue Jan 28 22:06:04 2025 -0500

    Okay actually now

commit 9afdc0aadc3f4fb4e811930bacff70a9e17eab9f
Author: Owen Schwartz <owen@txv.io>
Date:   Tue Jan 28 21:58:44 2025 -0500

    Migrations working finally

commit a7336b3b2466fe74d650b9c253ecadbe1eff749d
Merge: e7c7203 fdb1ab4
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 22:19:15 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit e7c7203330b1b08e570048b10ef314b55068e466
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 22:18:09 2025 -0500

    Working on migration

commit a4704dfd44b10647257c7c7054c0dae806d315bb
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:40:52 2025 -0500

    Add flag to allow raw resources

commit d74f7a57ed11e2a6bf1a7e0c28c29fb07eb573a0
Merge: 6817788 d791b9b
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:28:50 2025 -0500

    Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic

commit 68177882781b54ef30b62cca7dee8bbed7c5a2fa
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:28:32 2025 -0500

    Get everything working

commit d791b9b47f9f6ca050d6edfd1d674438f8562d99
Author: Milo Schwartz <mschwartz10612@gmail.com>
Date:   Mon Jan 27 17:46:19 2025 -0500

    fix orgId check in verifyAdmin

commit 6ac30afd7a449a126190d311bd98d7f1048f73a4
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 23:19:33 2025 -0500

    Trying to figure out traefik...

commit 9886b42272882f8bb6baff2efdbe26cee7cac2b6
Merge: 786e67e 85e9129
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 21:53:32 2025 -0500

    Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic

commit 786e67eadd6df1ee8df24e77aed20c1f1fc9ca67
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 21:51:37 2025 -0500

    Bug fixing

commit 85e9129ae313b2e4a460a8bc53a0af9f9fbbafb2
Author: Milo Schwartz <mschwartz10612@gmail.com>
Date:   Sun Jan 26 18:35:24 2025 -0500

    rethrow errors in migration and remove permanent redirect

commit bd82699505fc7510c27f72cd80ea0ce815d8c5ef
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:49:12 2025 -0500

    Fix merge issue

commit 933dbf3a02b1f19fd1f627410b2407fdf05cd9bf
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:46:13 2025 -0500

    Add sql to update resources and targets

commit f19437bad847c8dbf57fddd2c48cd17bab20ddb0
Merge: 58980eb 9f1f291
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:19:51 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit 58980ebb64d1040b4d224c76beb38c2254f3c5d9
Merge: 1de682a d284d36
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:10:09 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit 1de682a9f6039f40e05c8901c7381a94b0d018ed
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:08:29 2025 -0500

    Working on migrations

commit dc853d2bc02b11997be5c3c7ea789402716fb4c2
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 16:56:49 2025 -0500

    Finish config of resource pages

commit 37c681c08d7ab73d2cad41e7ef1dbe3a8852e1f2
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 16:07:25 2025 -0500

    Finish up table

commit 461c6650bbea0d7439cc042971ec13fdb52a7431
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 15:54:46 2025 -0500

    Working toward having dual resource types

commit f0894663627375e16ce6994370cb30b298efc2dc
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 22:31:25 2025 -0500

    Add qutoes

commit edc535b79b94c2e65b290cd90a69fe17d27245e9
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 22:28:45 2025 -0500

    Add readTimeout to allow long file uploads

commit 194892fa14b505bd7c2b31873dc13d4b8996c0e1
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 20:37:34 2025 -0500

    Rework traefik config generation

commit ad3f896b5333e4706d610c3198f29dcd67610365
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 13:01:47 2025 -0500

    Add proxy port to api

commit ca6013b2ffda0924a696ec3141825a54a4e5297d
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 12:58:01 2025 -0500

    Add migration

commit 2258d76cb3a49d3db7f05f76d8b8a9f1c248b5e4
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 12:55:02 2025 -0500

    Add new proxy port
Owen Schwartz 6 月之前
父节点
当前提交
0e04e82b88
共有 32 个文件被更改,包括 1009 次插入457 次删除
  1. 1 0
      config/config.example.yml
  2. 3 0
      config/traefik/traefik_config.example.yml
  3. 1 0
      install/fs/config.yml
  4. 10 1
      install/fs/traefik/traefik_config.yml
  5. 6 4
      server/db/schema.ts
  6. 6 1
      server/lib/config.ts
  7. 1 1
      server/middlewares/verifyAdmin.ts
  8. 1 1
      server/routers/badger/exchangeSession.ts
  9. 1 1
      server/routers/badger/verifySession.ts
  10. 7 5
      server/routers/internal.ts
  11. 67 31
      server/routers/newt/handleRegisterMessage.ts
  12. 36 65
      server/routers/newt/targets.ts
  13. 81 19
      server/routers/resource/createResource.ts
  14. 1 1
      server/routers/resource/deleteResource.ts
  15. 8 2
      server/routers/resource/listResources.ts
  16. 1 1
      server/routers/resource/updateResource.ts
  17. 3 8
      server/routers/target/createTarget.ts
  18. 2 4
      server/routers/target/deleteTarget.ts
  19. 0 1
      server/routers/target/listTargets.ts
  20. 3 5
      server/routers/target/updateTarget.ts
  21. 221 127
      server/routers/traefik/getTraefikConfig.ts
  22. 1 1
      server/routers/traefik/index.ts
  23. 110 8
      server/setup/scripts/1.0.0-beta9.ts
  24. 199 42
      src/app/[orgId]/settings/resources/CreateResourceForm.tsx
  25. 37 12
      src/app/[orgId]/settings/resources/ResourcesTable.tsx
  26. 59 35
      src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx
  27. 40 27
      src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx
  28. 89 49
      src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx
  29. 7 4
      src/app/[orgId]/settings/resources/[resourceId]/layout.tsx
  30. 3 0
      src/app/[orgId]/settings/resources/page.tsx
  31. 3 1
      src/lib/pullEnv.ts
  32. 1 0
      src/lib/types/env.ts

+ 1 - 0
config/config.example.yml

@@ -41,3 +41,4 @@ flags:
     require_email_verification: false
     disable_signup_without_invite: true
     disable_user_create_org: true
+    allow_raw_resources: true

+ 3 - 0
config/traefik/traefik_config.example.yml

@@ -33,6 +33,9 @@ entryPoints:
     address: ":80"
   websecure:
     address: ":443"
+    transport:
+      respondingTimeouts:
+        readTimeout: "30m"
     http:
       tls:
         certResolver: "letsencrypt"

+ 1 - 0
install/fs/config.yml

@@ -54,3 +54,4 @@ flags:
     require_email_verification: {{.EnableEmail}}
     disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
     disable_user_create_org: {{.DisableUserCreateOrg}}
+    allow_raw_resources: true

+ 10 - 1
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"
@@ -33,6 +39,9 @@ entryPoints:
     address: ":80"
   websecure:
     address: ":443"
+    transport:
+      respondingTimeouts:
+        readTimeout: "30m"
     http:
       tls:
         certResolver: "letsencrypt"

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

+ 6 - 1
server/lib/config.ts

@@ -151,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()
 });
@@ -254,6 +255,10 @@ 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.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";

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

+ 1 - 1
server/routers/badger/exchangeSession.ts

@@ -163,7 +163,7 @@ export async function exchangeSession(
         const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
         const cookie = serializeResourceSessionCookie(
             cookieName,
-            resource.fullDomain,
+            resource.fullDomain!,
             token,
             !resource.ssl
         );

+ 1 - 1
server/routers/badger/verifySession.ts

@@ -352,7 +352,7 @@ async function createAccessTokenSession(
     const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
     const cookie = serializeResourceSessionCookie(
         cookieName,
-        resource.fullDomain,
+        resource.fullDomain!,
         token,
         !resource.ssl
     );

+ 7 - 5
server/routers/internal.ts

@@ -1,11 +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 * as resource from "@server/routers/resource";
 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();
@@ -15,6 +16,7 @@ internalRouter.get("/", (_, res) => {
 });
 
 internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
+
 internalRouter.get(
     "/resource-session/:resourceId/:token",
     auth.checkResourceSession
@@ -24,7 +26,7 @@ internalRouter.post(
     `/resource/:resourceId/get-exchange-token`,
     verifySessionUserMiddleware,
     verifyResourceAccess,
-    resource.getExchangeToken
+    getExchangeToken
 );
 
 // Gerbil routes
@@ -38,7 +40,7 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth);
 const badgerRouter = Router();
 internalRouter.use("/badger", badgerRouter);
 
-badgerRouter.post("/verify-session", badger.verifyResourceSession);
-badgerRouter.post("/exchange-session", badger.exchangeSession);
+badgerRouter.post("/verify-session", verifyResourceSession);
+badgerRouter.post("/exchange-session", exchangeSession);
 
 export default internalRouter;

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

+ 81 - 19
server/routers/resource/createResource.ts

@@ -16,7 +16,6 @@ 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";
 
 const createResourceParamsSchema = z
@@ -28,11 +27,36 @@ const createResourceParamsSchema = z
 
 const createResourceSchema = z
     .object({
+        subdomain: z
+            .union([
+                z
+                    .string()
+                    .regex(
+                        /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
+                        "Invalid subdomain format"
+                    )
+                    .min(1, "Subdomain must be at least 1 character long")
+                    .transform((val) => val.toLowerCase()),
+                z.string().optional()
+            ])
+            .optional(),
         name: z.string().min(1).max(255),
-        subdomain: subdomainSchema
-    })
-    .strict();
-
+        http: z.boolean(),
+        protocol: z.string(),
+        proxyPort: z.number().int().min(1).max(65535).optional(),
+    }).refine(
+        (data) => {
+            if (data.http === true) {
+                return true;
+            }
+            return !!data.proxyPort;
+        },
+        {
+            message: "Port number is required for non-HTTP resources",
+            path: ["proxyPort"]
+        }
+    );
+    
 export type CreateResourceResponse = Resource;
 
 export async function createResource(
@@ -51,7 +75,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 +113,65 @@ 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 +209,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);
             }
         }
 

+ 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))

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

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

+ 221 - 127
server/routers/traefik/getTraefikConfig.ts

@@ -1,173 +1,267 @@
 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 redirectHttpsMiddlewareName = "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,
-                            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"
+        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`;
-
-            if (!resource || !resource.subdomain) {
-                continue;
-            }
+        for (const resource of allResources) {
+            const targets = JSON.parse(resource.targets);
+            const site = resource.site;
+            const org = resource.org;
 
-            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;
+                }
+
+                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 tls = {
-                certResolver: config.getRawConfig().traefik.cert_resolver,
-                ...(config.getRawConfig().traefik.prefer_wildcard_cert
-                    ? {
-                          domains: [
-                              {
-                                  main: wildCard,
-                              },
-                          ],
-                      }
-                    : {}),
-            };
-
-            const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || [];
-
-            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 (resource.ssl) {
-                http.routers![routerName + "-redirect"] = {
-                    entryPoints: [config.getRawConfig().traefik.http_entrypoint],
-                    middlewares: [redirectHttpsMiddlewareName],
+                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
+                                  }
+                              ]
+                          }
+                        : {})
+                };
+
+                config_output.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 (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 if (site.type === "local") {
-                http.services![serviceName] = {
+            } 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(`*`)" } : {})
+                };
+
+                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";

+ 110 - 8
server/setup/scripts/1.0.0-beta9.ts

@@ -3,12 +3,14 @@ import {
     emailVerificationCodes,
     passwordResetTokens,
     resourceOtp,
+    resources,
     resourceWhitelist,
+    targets,
     userInvites,
     users
 } from "@server/db/schema";
 import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
-import { sql } from "drizzle-orm";
+import { eq, sql } from "drizzle-orm";
 import fs from "fs";
 import yaml from "js-yaml";
 import path from "path";
@@ -33,11 +35,81 @@ export default async function migration() {
         });
     } catch (error) {
         console.log(
-            "We were unable to make all emails lower case in the database."
+            "We were unable to make all emails lower case in the database. You can safely ignore this error."
         );
         console.error(error);
     }
 
+    try {
+        await db.transaction(async (trx) => {
+           
+            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));
+            }
+
+        });
+    } catch (error) {
+        console.log(
+            "We were unable to make the changes to the targets and resources tables."
+        );
+        throw error;
+    }
+
     try {
         // Determine which config file exists
         const filePaths = [configFilePath1, configFilePath2];
@@ -81,7 +153,24 @@ export default async function migration() {
             "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({
@@ -101,26 +190,39 @@ export default async function migration() {
             throw new Error(fromZodError(parsedConfig.error).toString());
         }
 
+        // 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 the version of Badger in your Traefik configuration to v1.0.0-beta.3."
+            "Updated the version of Badger in your Traefik configuration to v1.0.0-beta.3 and added readTimeout to websecure entrypoint in your Traefik configuration.."
         );
     } catch (e) {
         console.log(
-            "We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
+            "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"
         );
-        console.error(e);
+        throw e;
     }
 
     try {
         await db.transaction(async (trx) => {
-            trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`);
-            trx.run(sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`);
+            trx.run(
+                sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`
+            );
+            trx.run(
+                sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`
+            );
         });
     } catch (e) {
         console.log(

+ 199 - 42
src/app/[orgId]/settings/resources/CreateResourceForm.tsx

@@ -45,21 +45,56 @@ 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";
 
-const accountFormSchema = z.object({
-    subdomain: subdomainSchema,
-    name: z.string(),
-    siteId: z.number()
-});
+const createResourceFormSchema = z
+    .object({
+        subdomain: z
+            .union([
+                z
+                    .string()
+                    .regex(
+                        /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
+                        "Invalid subdomain format"
+                    )
+                    .min(1, "Subdomain must be at least 1 character long")
+                    .transform((val) => val.toLowerCase()),
+                z.string().optional()
+            ])
+            .optional(),
+        name: z.string().min(1).max(255),
+        siteId: z.number(),
+        http: z.boolean(),
+        protocol: z.string(),
+        proxyPort: z.number().int().min(1).max(65535).optional()
+    })
+    .refine(
+        (data) => {
+            if (data.http === true) {
+                return true;
+            }
+            return !!data.proxyPort;
+        },
+        {
+            message: "Port number is required for non-HTTP resources",
+            path: ["proxyPort"]
+        }
+    );
 
-type AccountFormValues = z.infer<typeof accountFormSchema>;
+type CreateResourceFormValues = z.infer<typeof createResourceFormSchema>;
 
 type CreateResourceFormProps = {
     open: boolean;
@@ -81,15 +116,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 +150,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 +158,10 @@ 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
                 }
             )
             .catch((e) => {
@@ -188,34 +228,151 @@ 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") && (
+                                    <>
+                                        <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"

+ 37 - 12
src/app/[orgId]/settings/resources/ResourcesTable.tsx

@@ -39,6 +39,9 @@ export type ResourceRow = {
     site: string;
     siteId: string;
     hasAuth: boolean;
+    http: boolean;
+    protocol: string;
+    proxyPort: number | null;
 };
 
 type ResourcesTableProps = {
@@ -157,13 +160,29 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
                 );
             }
         },
+        {
+            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>

+ 40 - 27
src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx

@@ -94,7 +94,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 +129,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",
         }
     });
@@ -330,26 +330,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 +416,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,7 +457,7 @@ export default function ReverseProxyTargets(props: {
 
     return (
         <SettingsContainer>
-            {/* SSL Section */}
+            {resource.http && (
             <SettingsSection>
                 <SettingsSectionHeader>
                     <SettingsSectionTitle>
@@ -473,7 +479,7 @@ export default function ReverseProxyTargets(props: {
                     />
                 </SettingsSectionBody>
             </SettingsSection>
-
+)}
             {/* Targets Section */}
             <SettingsSection>
                 <SettingsSectionHeader>
@@ -491,6 +497,8 @@ export default function ReverseProxyTargets(props: {
                             className="space-y-4"
                         >
                             <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
+                                {resource.http && (
+
                                 <FormField
                                     control={addTargetForm.control}
                                     name="method"
@@ -499,7 +507,7 @@ export default function ReverseProxyTargets(props: {
                                             <FormLabel>Method</FormLabel>
                                             <FormControl>
                                                 <Select
-                                                    {...field}
+                                                    value={field.value || undefined}    
                                                     onValueChange={(value) => {
                                                         addTargetForm.setValue(
                                                             "method",
@@ -524,6 +532,8 @@ export default function ReverseProxyTargets(props: {
                                         </FormItem>
                                     )}
                                 />
+                            )}
+
                                 <FormField
                                     control={addTargetForm.control}
                                     name="ip"
@@ -637,6 +647,9 @@ export default function ReverseProxyTargets(props: {
                             </TableBody>
                         </Table>
                     </TableContainer>
+                    <SettingsSectionDescription>
+                        Multiple targets will get load balanced by Traefik. You can use this for high availability.
+                    </SettingsSectionDescription>
                 </SettingsSectionBody>
                 <SettingsSectionFooter>
                     <Button

+ 89 - 49
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,15 +34,25 @@ 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";
 
 const GeneralFormSchema = z.object({
-    name: z.string(),
-    subdomain: subdomainSchema
-    // siteId: z.number(),
+    subdomain: z
+        .union([
+            z
+                .string()
+                .regex(
+                    /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
+                    "Invalid subdomain format"
+                )
+                .min(1, "Subdomain must be at least 1 character long")
+                .transform((val) => val.toLowerCase()),
+            z.string().optional()
+        ])
+        .optional(),
+    name: z.string().min(1).max(255),
+    proxyPort: z.number().int().min(1).max(65535).optional()
 });
 
 type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -81,8 +76,8 @@ 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
         },
         mode: "onChange"
     });
@@ -169,33 +164,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 ||

+ 3 - 1
src/lib/pullEnv.ts

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

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

@@ -17,5 +17,6 @@ export type Env = {
         disableSignupWithoutInvite: boolean;
         disableUserCreateOrg: boolean;
         emailVerificationRequired: boolean;
+        allowRawResources: boolean;
     }
 };