diff --git a/config/config.example.yml b/config/config.example.yml index df4170a2..788b5943 100644 --- a/config/config.example.yml +++ b/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 \ No newline at end of file diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index 3b4512d5..8a2263ad 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -33,6 +33,9 @@ entryPoints: address: ":80" websecure: address: ":443" + transport: + respondingTimeouts: + readTimeout: "30m" http: tls: certResolver: "letsencrypt" diff --git a/install/fs/config.yml b/install/fs/config.yml index e7325562..3ccec1e5 100644 --- a/install/fs/config.yml +++ b/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 diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index de104a2f..4a68fdd5 100644 --- a/install/fs/traefik/traefik_config.yml +++ b/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" diff --git a/server/db/schema.ts b/server/db/schema.ts index acdda169..b87acd91 100644 --- a/server/db/schema.ts +++ b/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) }); diff --git a/server/lib/config.ts b/server/lib/config.ts index 9ef4ca9b..66dd6764 100644 --- a/server/lib/config.ts +++ b/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"; diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 08ab3d09..b2a773e1 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/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") ); diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 1af960aa..18925279 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/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 ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a71e6b3c..b1a6fb12 100644 --- a/server/routers/badger/verifySession.ts +++ b/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 ); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 30f7fd6d..2d68b368 100644 --- a/server/routers/internal.ts +++ b/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; diff --git a/server/routers/newt/handleRegisterMessage.ts b/server/routers/newt/handleRegisterMessage.ts index 2721ec86..704382eb 100644 --- a/server/routers/newt/handleRegisterMessage.ts +++ b/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`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) ) - ); + ) + .groupBy(resources.resourceId); - const udpTargets = siteTargets - .filter((target) => target.protocol === "udp") - .map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; - }); + let tcpTargets: string[] = []; + let udpTargets: string[] = []; - const tcpTargets = siteTargets - .filter((target) => target.protocol === "tcp") - .map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; - }); + 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: { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 1d456329..e5f7855c 100644 --- a/server/routers/newt/targets.ts +++ b/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 { +export async function addTargets( + newtId: string, + targets: Target[], + protocol: string +): Promise { //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 payloadTargets = targets.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 payload = { + type: `newt/${protocol}/add`, + data: { + targets: payloadTargets + } + }; + sendToClient(newtId, payload); } - -export async function removeTargets(newtId: string, targets: Target[]): Promise { +export async function removeTargets( + newtId: string, + targets: Target[], + protocol: string +): Promise { //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 payloadTargets = targets.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 payload = { + type: `newt/${protocol}/remove`, + data: { + targets: payloadTargets + } + }; + sendToClient(newtId, payload); } diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 00cad947..ee392e5f 100644 --- a/server/routers/resource/createResource.ts +++ b/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") diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index c9046017..ed0fc95f 100644 --- a/server/routers/resource/deleteResource.ts +++ b/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); } } diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 74c1cff3..50aebb76 100644 --- a/server/routers/resource/listResources.ts +++ b/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)) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6a0e7301..9a80fb7d 100644 --- a/server/routers/resource/updateResource.ts +++ b/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, { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 9ade677f..3d5e8d0e 100644 --- a/server/routers/target/createTarget.ts +++ b/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); } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 06be64e2..97dab71c 100644 --- a/server/routers/target/deleteTarget.ts +++ b/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); } } diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index e430ed18..0efea3ec 100644 --- a/server/routers/target/listTargets.ts +++ b/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, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 5bc7ad53..4125fd9c 100644 --- a/server/routers/target/updateTarget.ts +++ b/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, { diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index d8832c61..4b760274 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/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 { 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`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`; + for (const resource of allResources) { + const targets = JSON.parse(resource.targets); + const site = resource.site; + const org = resource.org; - if (!resource || !resource.subdomain) { - continue; - } - - if (!org || !org.domain) { + if (!org.domain) { continue; } + const routerName = `${resource.resourceId}-router`; + const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.subdomain}.${org.domain}`; - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; - } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; - } + if (resource.http) { + // HTTP configuration remains the same + if (!resource.subdomain) { + continue; + } - const tls = { - certResolver: config.getRawConfig().traefik.cert_resolver, - ...(config.getRawConfig().traefik.prefer_wildcard_cert - ? { - domains: [ - { - main: wildCard, - }, - ], - } - : {}), - }; + if ( + targets.filter( + (target: Target) => target.internalPort != null + ).length == 0 + ) { + continue; + } - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + // add routers and services empty objects if they don't exist + if (!config_output.http.routers) { + config_output.http.routers = {}; + } - 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 (!config_output.http.services) { + config_output.http.services = {}; + } - 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 ], - }, + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: `Host(\`${fullDomain}\`)` + }; + } + + config_output.http.services![serviceName] = { + loadBalancer: { + 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 === "wireguard") { - http.services![serviceName] = { - loadBalancer: { - servers: [ - { - url: `${target.method}://${target.ip}:${target.port}`, - }, - ], - }, + } else { + // Non-HTTP (TCP/UDP) configuration + const protocol = resource.protocol.toLowerCase(); + const port = resource.proxyPort; + + if (!port) { + continue; + } + + if ( + targets.filter( + (target: Target) => target.internalPort != null + ).length == 0 + ) { + continue; + } + + if (!config_output[protocol]) { + config_output[protocol] = { + routers: {}, + services: {} + }; + } + + config_output[protocol].routers[routerName] = { + entryPoints: [`${protocol}-${port}`], + service: serviceName, + ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) }; - } else if (site.type === "local") { - http.services![serviceName] = { + + config_output[protocol].services[serviceName] = { loadBalancer: { - servers: [ - { - url: `${target.method}://${target.ip}:${target.port}`, - }, - ], - }, + servers: targets + .filter( + (target: Target) => target.internalPort != null + ) + .map((target: Target) => { + if ( + site.type === "local" || + site.type === "wireguard" + ) { + return { + address: `${target.ip}:${target.port}` + }; + } else if (site.type === "newt") { + const ip = site.subnet.split("/")[0]; + return { + address: `${ip}:${target.internalPort}` + }; + } + }) + } }; } } - - return res.status(HttpCode.OK).json({ http }); + return res.status(HttpCode.OK).json(config_output); } catch (e) { logger.error(`Failed to build traefik config: ${e}`); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ - error: "Failed to build traefik config", + error: "Failed to build traefik config" }); } } diff --git a/server/routers/traefik/index.ts b/server/routers/traefik/index.ts index 0fc483fa..5630028c 100644 --- a/server/routers/traefik/index.ts +++ b/server/routers/traefik/index.ts @@ -1 +1 @@ -export * from "./getTraefikConfig"; +export * from "./getTraefikConfig"; \ No newline at end of file diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index 9cccd554..f82f7012 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/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( diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 4ec4e8d5..7a111c06 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/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; +type CreateResourceFormValues = z.infer; 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([]); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); - const form = useForm({ - resolver: zodResolver(accountFormSchema), + const form = useForm({ + 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({ )} /> - ( - - Subdomain - - - form.setValue( - "subdomain", - value - ) - } - /> - - - This is the fully qualified - domain name that will be used to - access the resource. - - - - )} - /> + + {!env.flags.allowRawResources || ( + ( + +
+ + HTTP Resource + + + Toggle if this is an + HTTP resource or a raw + TCP/UDP resource + +
+ + + +
+ )} + /> + )} + + {form.watch("http") && ( + ( + + Subdomain + + + form.setValue( + "subdomain", + value + ) + } + /> + + + This is the fully qualified + domain name that will be + used to access the resource. + + + + )} + /> + )} + + {!form.watch("http") && ( + <> + ( + + + Protocol + + + + The protocol to use for + the resource + + + + )} + /> + ( + + + Port Number + + + + field.onChange( + e.target + .value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + The port number to proxy + requests to (required + for non-HTTP resources) + + + + )} + /> + + )} + { const resourceRow = row.original; return ( + {resourceRow.protocol.toUpperCase()} + ); + } + }, + { + accessorKey: "domain", + header: "Access", + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {!resourceRow.http ? ( + + ) : ( + )} +
); } }, @@ -186,17 +205,23 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
- {resourceRow.hasAuth ? ( - - - Protected - - ) : ( - - - Not Protected - - )} + + + {!resourceRow.http ? ( + -- + ) : + resourceRow.hasAuth ? ( + + + Protected + + ) : ( + + + Not Protected + + ) + }
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index a4d8289f..c7f51622 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/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) { - - Authentication - - {authInfo.password || - authInfo.pincode || - authInfo.sso || - authInfo.whitelist ? ( -
- - - This resource is protected with at least - one auth method. - -
- ) : ( -
- - - Anyone can access this resource. - -
- )} -
-
- - - URL - - - - + {resource.http ? ( + <> + + + Authentication + + + {authInfo.password || + authInfo.pincode || + authInfo.sso || + authInfo.whitelist ? ( +
+ + + This resource is protected with + at least one auth method. + +
+ ) : ( +
+ + + Anyone can access this resource. + +
+ )} +
+
+ + + URL + + + + + + ) : ( + <> + + Protocol + + {resource.protocol.toUpperCase()} + + + + + Port + + + + + + )}
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 4b12f661..2e38b193 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/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[] = [ - { - accessorKey: "method", - header: "Method", - cell: ({ row }) => ( - - ) - }, { accessorKey: "ip", header: "IP / Hostname", @@ -436,6 +416,32 @@ export default function ReverseProxyTargets(props: { } ]; + if (resource.http) { + const methodCol: ColumnDef = { + accessorKey: "method", + header: "Method", + cell: ({ row }) => ( + + ) + }; + + // 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 ( - {/* SSL Section */} + {resource.http && ( @@ -473,7 +479,7 @@ export default function ReverseProxyTargets(props: { /> - +)} {/* Targets Section */} @@ -491,6 +497,8 @@ export default function ReverseProxyTargets(props: { className="space-y-4" >
+ {resource.http && ( + Method + field.onChange( + e.target.value + ? parseInt( + e + .target + .value + ) + : null + ) + } + /> + + + This is the port that will + be used to access the + resource. + + + + )} + /> + )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 5f5b90fa..5506866e 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/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: , - }, - { + } + ]; + + if (resource.http) { + sidebarNavItems.push({ title: "Authentication", href: `/{orgId}/settings/resources/{resourceId}/authentication` // icon: , - } - ]; + }); + } return ( <> diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index c8348b5d..f9b5558b 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/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 || diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index 564ba0bc..368df440 100644 --- a/src/lib/pullEnv.ts +++ b/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, } }; } diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 9a2e5b93..14efd1be 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -17,5 +17,6 @@ export type Env = { disableSignupWithoutInvite: boolean; disableUserCreateOrg: boolean; emailVerificationRequired: boolean; + allowRawResources: boolean; } };