From 6b141c3ea01e259d488227d99194a8e8cf2ebbe0 Mon Sep 17 00:00:00 2001 From: Mauricio Allende Date: Fri, 17 Jan 2025 22:54:20 -0300 Subject: [PATCH 01/56] fix: add missing `await` when verifying pincode `validPincode` ends up as a `Promise` and evaluates as a thruthy value wether the pin is correct or not. --- server/routers/resource/authWithPincode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index a41f5a29..63987a6d 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -117,7 +117,7 @@ export async function authWithPincode( ); } - const validPincode = verifyPassword( + const validPincode = await verifyPassword( pincode, definedPincode.pincodeHash ); From a208ab36b8b067fa82a9f9a51e50f5ea49e753d7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 17 Jan 2025 21:53:16 -0500 Subject: [PATCH 02/56] Bump version --- install/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/main.go b/install/main.go index 3267c398..85e739df 100644 --- a/install/main.go +++ b/install/main.go @@ -18,7 +18,7 @@ import ( ) func loadVersions(config *Config) { - config.PangolinVersion = "1.0.0-beta.6" + config.PangolinVersion = "1.0.0-beta.7" config.GerbilVersion = "1.0.0-beta.2" } From f0898613a27c6f60ab3ea1d1a5138e73c67753f7 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 17 Jan 2025 21:59:06 -0500 Subject: [PATCH 03/56] remove double createHttpError --- server/routers/resource/authWithPincode.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 63987a6d..97a0b13c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -109,10 +109,7 @@ export async function authWithPincode( return next( createHttpError( HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Resource has no pincode protection" - ) + "Resource has no pincode protection" ) ); } From 4eca127781a7b1fdd06acd2f2d4b74b5f690f77f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 19 Jan 2025 17:33:46 -0500 Subject: [PATCH 04/56] Update gerbil version --- install/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/main.go b/install/main.go index 85e739df..a9cd58e7 100644 --- a/install/main.go +++ b/install/main.go @@ -19,7 +19,7 @@ import ( func loadVersions(config *Config) { config.PangolinVersion = "1.0.0-beta.7" - config.GerbilVersion = "1.0.0-beta.2" + config.GerbilVersion = "1.0.0-beta.3" } //go:embed fs/* From 72d7ecb2ed378e4014ba04fd961cd745140bde01 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 19 Jan 2025 17:36:48 -0500 Subject: [PATCH 05/56] Update clean --- install/Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/install/Makefile b/install/Makefile index acc663ae..45ad5968 100644 --- a/install/Makefile +++ b/install/Makefile @@ -9,6 +9,6 @@ release: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64 clean: - rm bin/installer - rm bin/installer_linux_amd64 - rm bin/installer_linux_arm64 + rm -f bin/installer + rm -f bin/installer_linux_amd64 + rm -f bin/installer_linux_arm64 From 2c8f82424002c2893a74fd8f184aa07001fcbf4a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 20 Jan 2025 21:07:02 -0500 Subject: [PATCH 06/56] Pick always a new port for newt --- server/routers/target/createTarget.ts | 35 ++---------------- server/routers/target/ports.ts | 48 ++++++++++++++++++++++++ server/routers/target/updateTarget.ts | 53 +++++++++++++++------------ 3 files changed, 80 insertions(+), 56 deletions(-) create mode 100644 server/routers/target/ports.ts diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 1376ab0a..9ade677f 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -7,10 +7,11 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { addPeer } from "../gerbil/peers"; -import { eq, and } from "drizzle-orm"; import { isIpInCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; +import { eq } from "drizzle-orm"; +import { pickPort } from "./ports"; // Regular expressions for validation const DOMAIN_REGEX = @@ -147,37 +148,7 @@ export async function createTarget( ); } - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // TODO: is this all inefficient? - // Fetch targets for all resources of this site - let targetIps: string[] = []; - let targetInternalPorts: number[] = []; - await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - targetsRes.forEach((target) => { - targetIps.push(`${target.ip}/32`); - if (target.internalPort) { - targetInternalPorts.push(target.internalPort); - } - }); - }) - ); - - let internalPort!: number; - // pick a port - for (let i = 40000; i < 65535; i++) { - if (!targetInternalPorts.includes(i)) { - internalPort = i; - break; - } - } + const { internalPort, targetIps } = await pickPort(site.siteId!); if (!internalPort) { return next( diff --git a/server/routers/target/ports.ts b/server/routers/target/ports.ts new file mode 100644 index 00000000..bfa8f280 --- /dev/null +++ b/server/routers/target/ports.ts @@ -0,0 +1,48 @@ +import { db } from "@server/db"; +import { resources, targets } from "@server/db/schema"; +import { eq } from "drizzle-orm"; + +let currentBannedPorts: number[] = []; + +export async function pickPort(siteId: number): Promise<{ + internalPort: number; + targetIps: string[]; +}> { + // Fetch resources for this site + const resourcesRes = await db.query.resources.findMany({ + where: eq(resources.siteId, siteId) + }); + + // TODO: is this all inefficient? + // Fetch targets for all resources of this site + let targetIps: string[] = []; + let targetInternalPorts: number[] = []; + await Promise.all( + resourcesRes.map(async (resource) => { + const targetsRes = await db.query.targets.findMany({ + where: eq(targets.resourceId, resource.resourceId) + }); + targetsRes.forEach((target) => { + targetIps.push(`${target.ip}/32`); + if (target.internalPort) { + targetInternalPorts.push(target.internalPort); + } + }); + }) + ); + + let internalPort!: number; + // pick a port random port from 40000 to 65535 that is not in use + for (let i = 0; i < 1000; i++) { + internalPort = Math.floor(Math.random() * 25535) + 40000; + if ( + !targetInternalPorts.includes(internalPort) && + !currentBannedPorts.includes(internalPort) + ) { + break; + } + } + currentBannedPorts.push(internalPort); + + return { internalPort, targetIps }; +} diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 77f127e8..5bc7ad53 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +import { pickPort } from "./ports"; // Regular expressions for validation const DOMAIN_REGEX = @@ -84,15 +85,14 @@ export async function updateTarget( } const { targetId } = parsedParams.data; - const updateData = parsedBody.data; - const [updatedTarget] = await db - .update(targets) - .set(updateData) + const [target] = await db + .select() + .from(targets) .where(eq(targets.targetId, targetId)) - .returning(); + .limit(1); - if (!updatedTarget) { + if (!target) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -107,13 +107,13 @@ export async function updateTarget( siteId: resources.siteId }) .from(resources) - .where(eq(resources.resourceId, updatedTarget.resourceId!)); + .where(eq(resources.resourceId, target.resourceId!)); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, - `Resource with ID ${updatedTarget.resourceId} not found` + `Resource with ID ${target.resourceId} not found` ) ); } @@ -132,24 +132,29 @@ export async function updateTarget( ) ); } + + const { internalPort, targetIps } = await pickPort(site.siteId!); + + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } + + const [updatedTarget] = await db + .update(targets) + .set({ + ...parsedBody.data, + internalPort + }) + .where(eq(targets.targetId, targetId)) + .returning(); + if (site.pubKey) { if (site.type == "wireguard") { - // TODO: is this all inefficient? - // Fetch resources for this site - const resourcesRes = await db.query.resources.findMany({ - where: eq(resources.siteId, site.siteId) - }); - - // Fetch targets for all resources of this site - const targetIps = await Promise.all( - resourcesRes.map(async (resource) => { - const targetsRes = await db.query.targets.findMany({ - where: eq(targets.resourceId, resource.resourceId) - }); - return targetsRes.map((target) => `${target.ip}/32`); - }) - ); - await addPeer(site.exitNodeId!, { publicKey: site.pubKey, allowedIps: targetIps.flat() From b4620cfea6235ac4cee1778357444dbce3a4bc8b Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 20 Jan 2025 21:30:34 -0500 Subject: [PATCH 07/56] bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed4e7f27..f5650f81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.6", + "version": "1.0.0-beta.8", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", From 273d9675bfd94f516b9ea01f39cb6cea82e5fb37 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 20 Jan 2025 21:31:38 -0500 Subject: [PATCH 08/56] Bump version --- install/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/main.go b/install/main.go index a9cd58e7..2f1f48ac 100644 --- a/install/main.go +++ b/install/main.go @@ -18,7 +18,7 @@ import ( ) func loadVersions(config *Config) { - config.PangolinVersion = "1.0.0-beta.7" + config.PangolinVersion = "1.0.0-beta.8" config.GerbilVersion = "1.0.0-beta.3" } From 19232a81ef1145ba08d0cfa11c5e3fadcfb0fc2d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 21 Jan 2025 15:24:48 -0500 Subject: [PATCH 09/56] Create FUNDING.yml --- .github/FUNDING.yml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..e3d0a447 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [fosrl] From 5f92b0bbc1af4cf469b5af42ea3341f83e91e61f Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 21 Jan 2025 18:36:50 -0500 Subject: [PATCH 10/56] make all emails lowercase closes #89 --- server/lib/config.ts | 6 ++- server/routers/auth/login.ts | 5 ++- server/routers/auth/requestPasswordReset.ts | 10 ++--- server/routers/auth/resetPassword.ts | 5 ++- server/routers/auth/signup.ts | 2 +- server/routers/resource/authWithWhitelist.ts | 5 ++- .../routers/resource/setResourceWhitelist.ts | 5 ++- server/routers/user/inviteUser.ts | 5 ++- server/setup/migrations.ts | 5 ++- server/setup/scripts/1.0.0-beta9.ts | 40 +++++++++++++++++++ 10 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta9.ts diff --git a/server/lib/config.ts b/server/lib/config.ts index 49287339..53dac62e 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -37,7 +37,8 @@ const configSchema = z.object({ base_domain: hostnameSchema .optional() .transform(getEnvOrYaml("APP_BASEDOMAIN")) - .pipe(hostnameSchema), + .pipe(hostnameSchema) + .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean() }), @@ -123,7 +124,8 @@ const configSchema = z.object({ .email() .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL")) - .pipe(z.string().email()), + .pipe(z.string().email()) + .transform((v) => v.toLowerCase()), password: passwordSchema .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD")) diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 328caeb9..7ee0d927 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession"; export const loginBodySchema = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), password: z.string(), code: z.string().optional() }) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index a223e5f2..36cfed87 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -20,7 +20,10 @@ import { hashPassword } from "@server/auth/password"; export const requestPasswordResetBody = z .object({ - email: z.string().email() + email: z + .string() + .email() + .transform((v) => v.toLowerCase()) }) .strict(); @@ -63,10 +66,7 @@ export async function requestPasswordReset( ); } - const token = generateRandomString( - 8, - alphabet("0-9", "A-Z", "a-z") - ); + const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); await db.transaction(async (trx) => { await trx .delete(passwordResetTokens) diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 6f36d006..45c8652b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema"; export const resetPasswordBody = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), token: z.string(), // reset secret code newPassword: passwordSchema, code: z.string().optional() // 2fa code diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 9710d858..2a4bb127 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -23,7 +23,7 @@ import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; export const signupBodySchema = z.object({ - email: z.string().email(), + email: z.string().email().transform((v) => v.toLowerCase()), password: passwordSchema, inviteToken: z.string().optional(), inviteId: z.string().optional() diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index cc73410c..54c43f62 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -24,7 +24,10 @@ import logger from "@server/logger"; const authWithWhitelistBodySchema = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), otp: z.string().optional() }) .strict(); diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 5eefbd53..8931d4ff 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -11,7 +11,10 @@ import { and, eq } from "drizzle-orm"; const setResourceWhitelistBodySchema = z .object({ - emails: z.array(z.string().email()).max(50) + emails: z + .array(z.string().email()) + .max(50) + .transform((v) => v.map((e) => e.toLowerCase())) }) .strict(); diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 3031e399..45240ed2 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -23,7 +23,10 @@ const inviteUserParamsSchema = z const inviteUserBodySchema = z .object({ - email: z.string().email(), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), roleId: z.number(), validHours: z.number().gt(0).lte(168), sendEmail: z.boolean().optional() diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 0e9e4819..6d2766ee 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -11,7 +11,7 @@ import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; import m4 from "./scripts/1.0.0-beta5"; import m5 from "./scripts/1.0.0-beta6"; -import { existsSync, mkdirSync } from "fs"; +import m6 from "./scripts/1.0.0-beta9"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -22,7 +22,8 @@ const migrations = [ { version: "1.0.0-beta.2", run: m2 }, { version: "1.0.0-beta.3", run: m3 }, { version: "1.0.0-beta.5", run: m4 }, - { version: "1.0.0-beta.6", run: m5 } + { version: "1.0.0-beta.6", run: m5 }, + { version: "1.0.0-beta.9", run: m6 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts new file mode 100644 index 00000000..b5bd7838 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -0,0 +1,40 @@ +import db from "@server/db"; +import { + emailVerificationCodes, + passwordResetTokens, + resourceOtp, + resourceWhitelist, + userInvites, + users +} from "@server/db/schema"; +import { sql } from "drizzle-orm"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.9..."); + + try { + await db.transaction(async (trx) => { + await db.transaction(async (trx) => { + trx.run(sql`UPDATE ${users} SET email = LOWER(email);`); + trx.run( + sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);` + ); + trx.run( + sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);` + ); + trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`); + trx.run( + sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);` + ); + trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`); + }); + }); + } catch (error) { + console.log( + "We were unable to make all emails lower case in the database." + ); + console.error(error); + } + + console.log("Done."); +} From 9a831e8e34a6bff81d286e355a5258f43df7777a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 21:26:59 -0500 Subject: [PATCH 11/56] use id for data-value closes #86 --- src/app/[orgId]/settings/resources/CreateResourceForm.tsx | 2 +- src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 912f7be1..4ec4e8d5 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -257,7 +257,7 @@ export default function CreateResourceForm({ (site) => ( ( Date: Thu, 23 Jan 2025 22:23:50 -0500 Subject: [PATCH 12/56] use quotes around strings in yaml closes #96 --- config/config.example.yml | 26 +++++++++++++------------- install/fs/config.yml | 36 ++++++++++++++++++------------------ install/main.go | 9 +++++++-- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index d1d299b7..aca7bfe0 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,27 +1,27 @@ app: - dashboard_url: http://localhost:3002 - base_domain: localhost - log_level: info + dashboard_url: "http://localhost:3002" + base_domain: "localhost" + log_level: "info" save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: pangolin + internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: p_session - resource_session_cookie_name: p_resource_session - resource_access_token_param: p_token + session_cookie_name: "p_session" + resource_session_cookie_name: "p_resource_session" + resource_access_token_param: "p_token" traefik: - cert_resolver: letsencrypt - http_entrypoint: web - https_entrypoint: websecure + cert_resolver: "letsencrypt" + http_entrypoint: "web" + https_entrypoint: "websecure" gerbil: start_port: 51820 - base_endpoint: localhost + base_endpoint: "localhost" block_size: 24 site_block_size: 30 subnet_group: 100.89.137.0/20 @@ -34,8 +34,8 @@ rate_limits: users: server_admin: - email: admin@example.com - password: Password123! + email: "admin@example.com" + password: "Password123!" flags: require_email_verification: false diff --git a/install/fs/config.yml b/install/fs/config.yml index 91d67019..6cea3f13 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -1,18 +1,18 @@ app: - dashboard_url: https://{{.DashboardDomain}} - base_domain: {{.BaseDomain}} - log_level: info + dashboard_url: "https://{{.DashboardDomain}}" + base_domain: "{{.BaseDomain}}" + log_level: "info" save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: pangolin + internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: p_session - resource_session_cookie_name: p_resource_session - resource_access_token_param: p_token + session_cookie_name: "p_session" + resource_session_cookie_name: "p_resource_session" + resource_access_token_param: "p_token" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] @@ -20,14 +20,14 @@ server: credentials: false traefik: - cert_resolver: letsencrypt - http_entrypoint: web - https_entrypoint: websecure + cert_resolver: "letsencrypt" + http_entrypoint: "web" + https_entrypoint: "websecure" prefer_wildcard_cert: false gerbil: start_port: 51820 - base_endpoint: {{.DashboardDomain}} + base_endpoint: "{{.DashboardDomain}}" use_subdomain: false block_size: 24 site_block_size: 30 @@ -39,16 +39,16 @@ rate_limits: max_requests: 100 {{if .EnableEmail}} email: - smtp_host: {{.EmailSMTPHost}} - smtp_port: {{.EmailSMTPPort}} - smtp_user: {{.EmailSMTPUser}} - smtp_pass: {{.EmailSMTPPass}} - no_reply: {{.EmailNoReply}} + smtp_host: "{{.EmailSMTPHost}}" + smtp_port: "{{.EmailSMTPPort}}" + smtp_user: "{{.EmailSMTPUser}}" + smtp_pass: "{{.EmailSMTPPass}}" + no_reply: "{{.EmailNoReply}}" {{end}} users: server_admin: - email: {{.AdminUserEmail}} - password: {{.AdminUserPassword}} + email: "{{.AdminUserEmail}}" + password: "{{.AdminUserPassword}}" flags: require_email_verification: {{.EnableEmail}} diff --git a/install/main.go b/install/main.go index 2f1f48ac..ba111b7f 100644 --- a/install/main.go +++ b/install/main.go @@ -271,6 +271,11 @@ func createConfigFiles(config Config) error { // Get the relative path by removing the "fs/" prefix relPath := strings.TrimPrefix(path, "fs/") + // skip .DS_Store + if strings.Contains(relPath, ".DS_Store") { + return nil + } + // Create the full output path under "config/" outPath := filepath.Join("config", relPath) @@ -374,7 +379,7 @@ func installDocker() error { switch { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` - apt-get update && + apt-get update && apt-get install -y apt-transport-https ca-certificates curl software-properties-common && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && @@ -383,7 +388,7 @@ func installDocker() error { `, dockerArch)) case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` - apt-get update && + apt-get update && apt-get install -y apt-transport-https ca-certificates curl software-properties-common && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && From 02b5f4d3906be405012f251aca4ec15cbfa318fc Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 22:34:12 -0500 Subject: [PATCH 13/56] increase hitbox for links in buttons --- .../settings/access/users/UsersTable.tsx | 16 +++++----- .../settings/resources/ResourcesTable.tsx | 30 +++++++++---------- .../settings/share-links/ShareLinksTable.tsx | 12 ++++---- src/app/[orgId]/settings/sites/SitesTable.tsx | 14 ++++----- 4 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 5b365e8d..03454bf1 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -17,7 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -75,14 +75,14 @@ export default function UsersTable({ users: u }: UsersTableProps) { - - + + Manage User - - + + {userRow.email !== user?.email && ( { diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index e80fa39e..e065b38e 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -25,7 +25,7 @@ import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { set } from "zod"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { useToast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -91,14 +91,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - - + + View settings - - + + { setSelectedResource(resourceRow); @@ -146,14 +146,14 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { cell: ({ row }) => { const resourceRow = row.original; return ( - + + + ); } }, diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 451bec9f..8fadbd10 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -145,14 +145,12 @@ export default function ShareLinksTable({ cell: ({ row }) => { const r = row.original; return ( - + + + ); } }, diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index e76203a3..eaa1ca6c 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -92,14 +92,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { - - + + View settings - - + + { setSelectedSite(siteRow); From 2d0a367f1a392afa2016890a4e4a789df34829a7 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 23 Jan 2025 22:38:35 -0500 Subject: [PATCH 14/56] fix link in resource alert not updating when changing ssl --- .../settings/resources/[resourceId]/connectivity/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 30a645f0..4b12f661 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -321,7 +321,7 @@ export default function ReverseProxyTargets(props: { }); setSslEnabled(val); - updateResource({ ssl: sslEnabled }); + updateResource({ ssl: val }); toast({ title: "SSL Configuration", From 8e5330fb82c8fecac49f62f82bab1178ecbfd50f Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 24 Jan 2025 23:18:27 -0500 Subject: [PATCH 15/56] add cicd --- .github/workflows/cicd.yml | 75 ++++++++++++++++++++++++++++++++++++++ .gitignore | 2 + install/Makefile | 1 - install/main.go | 5 ++- package.json | 2 +- 5 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cicd.yml diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml new file mode 100644 index 00000000..b2e9bdf3 --- /dev/null +++ b/.github/workflows/cicd.yml @@ -0,0 +1,75 @@ +name: CI/CD Pipeline + +on: + push: + tags: + - "*" + +jobs: + release: + name: Build and Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: 1.23.0 + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + if [ -f package.json ]; then + jq --arg version "$TAG" '.version = $version' package.json > package.tmp.json && mv package.tmp.json package.json + echo "Updated package.json with version $TAG" + else + echo "package.json not found" + fi + cat package.json + + - name: Pull latest Gerbil version + id: get-gerbil-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') + echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Update install/main.go + run: | + PANGOLIN_VERSION=${{ env.TAG }} + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go + echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION and Gerbil version $GERBIL_VERSION" + cat install/main.go + + - name: Build and push Docker images + run: | + TAG=${{ env.TAG }} + make build-release tag=$TAG + + - name: Build installer + working-directory: install + run: | + make release + + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@v3 + with: + name: install-bin + path: install/bin/ diff --git a/.gitignore b/.gitignore index dacf66a1..63742d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ dist installer *.tar bin +.secrets +test_event.json diff --git a/install/Makefile b/install/Makefile index 45ad5968..e8e9cd2e 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,4 +1,3 @@ - all: build build: diff --git a/install/main.go b/install/main.go index ba111b7f..b21d8df5 100644 --- a/install/main.go +++ b/install/main.go @@ -17,9 +17,10 @@ import ( "golang.org/x/term" ) +// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD func loadVersions(config *Config) { - config.PangolinVersion = "1.0.0-beta.8" - config.GerbilVersion = "1.0.0-beta.3" + config.PangolinVersion = "replaceme" + config.GerbilVersion = "replaceme" } //go:embed fs/* diff --git a/package.json b/package.json index f5650f81..c67718ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.8", + "version": "0.0.0", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", From 6cc6b0c23952cb51578b15b57560f8400a15a05e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 25 Jan 2025 12:27:27 -0500 Subject: [PATCH 16/56] docker-compose vs docker compose; Resolves #83 --- install/main.go | 62 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/install/main.go b/install/main.go index b21d8df5..7436c553 100644 --- a/install/main.go +++ b/install/main.go @@ -438,29 +438,53 @@ func isDockerInstalled() bool { return true } +func getCommandString(useNewStyle bool) string { + if useNewStyle { + return "'docker compose'" + } + return "'docker-compose'" +} + func pullAndStartContainers() error { fmt.Println("Starting containers...") - // First try docker compose (new style) - cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err := cmd.Run() - - if err != nil { - fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command") - os.Exit(1) + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } } - cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - - if err != nil { - fmt.Println("Failed to start containers using docker-compose command") - os.Exit(1) + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } - return err -} + // Pull containers + fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle)) + if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil { + return fmt.Errorf("failed to pull containers: %v", err) + } + + // Start containers + fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle)) + if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil +} \ No newline at end of file From d284d36c243707a01d070682e86705b6869ebd3b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 25 Jan 2025 12:55:19 -0500 Subject: [PATCH 17/56] Remove double transaction --- server/setup/scripts/1.0.0-beta9.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index b5bd7838..e4f62659 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -14,20 +14,16 @@ export default async function migration() { try { await db.transaction(async (trx) => { - await db.transaction(async (trx) => { - trx.run(sql`UPDATE ${users} SET email = LOWER(email);`); - trx.run( - sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);` - ); - trx.run( - sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);` - ); - trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`); - trx.run( - sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);` - ); - trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`); - }); + trx.run(sql`UPDATE ${users} SET email = LOWER(email);`); + trx.run( + sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);` + ); + trx.run( + sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);` + ); + trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`); + trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`); + trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`); }); } catch (error) { console.log( From 72f16863954ce2ce15237e6f197361cf99a1d695 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 25 Jan 2025 13:23:36 -0500 Subject: [PATCH 18/56] remove permanent redirect for https --- config/traefik/dynamic_config.example.yml | 1 - install/fs/traefik/dynamic_config.yml | 1 - package.json | 2 +- server/routers/traefik/getTraefikConfig.ts | 3 +-- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/config/traefik/dynamic_config.example.yml b/config/traefik/dynamic_config.example.yml index bd76851b..cca0ea18 100644 --- a/config/traefik/dynamic_config.example.yml +++ b/config/traefik/dynamic_config.example.yml @@ -3,7 +3,6 @@ http: redirect-to-https: redirectScheme: scheme: https - permanent: true routers: # HTTP to HTTPS redirect router diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index 770c30ba..8fcf8e55 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -3,7 +3,6 @@ http: redirect-to-https: redirectScheme: scheme: https - permanent: true routers: # HTTP to HTTPS redirect router diff --git a/package.json b/package.json index c67718ad..cc28ab10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "0.0.0", + "version": "1.0.0-beta.9", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index fb775ac4..1153a532 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -62,8 +62,7 @@ export async function traefikConfigProvider( }, [redirectMiddlewareName]: { redirectScheme: { - scheme: "https", - permanent: true, + scheme: "https" }, }, }, From 9f1f2910e421fe7b79fb16a3d4faeb82b6c71b82 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 26 Jan 2025 14:42:02 -0500 Subject: [PATCH 19/56] refactor auth to work cross domain and with http resources closes #100 --- config/config.example.yml | 4 +- install/fs/config.yml | 4 +- package.json | 1 + server/auth/sessions/app.ts | 42 +++-- server/auth/sessions/resource.ts | 41 ++-- server/db/schema.ts | 4 + server/index.ts | 3 +- server/lib/config.ts | 18 +- server/routers/auth/logout.ts | 11 +- server/routers/badger/exchangeSession.ts | 175 ++++++++++++++++++ server/routers/badger/index.ts | 1 + server/routers/badger/verifySession.ts | 175 ++++++++++++------ server/routers/internal.ts | 12 +- .../routers/resource/authWithAccessToken.ts | 16 +- server/routers/resource/authWithPassword.ts | 11 +- server/routers/resource/authWithPincode.ts | 18 +- server/routers/resource/authWithWhitelist.ts | 12 +- server/routers/resource/getExchangeToken.ts | 109 +++++++++++ server/routers/resource/index.ts | 1 + server/routers/resource/updateResource.ts | 4 + server/routers/traefik/getTraefikConfig.ts | 12 +- server/setup/scripts/1.0.0-beta9.ts | 97 ++++++++++ .../resource/[resourceId]/AccessToken.tsx | 19 +- .../[resourceId]/ResourceAuthPortal.tsx | 37 ++-- src/app/auth/resource/[resourceId]/page.tsx | 56 +++--- src/lib/pullEnv.ts | 4 +- src/lib/types/env.ts | 2 +- 27 files changed, 688 insertions(+), 201 deletions(-) create mode 100644 server/routers/badger/exchangeSession.ts create mode 100644 server/routers/resource/getExchangeToken.ts diff --git a/config/config.example.yml b/config/config.example.yml index aca7bfe0..df4170a2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -10,9 +10,9 @@ server: next_port: 3002 internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: "p_session" - resource_session_cookie_name: "p_resource_session" + session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" traefik: cert_resolver: "letsencrypt" diff --git a/install/fs/config.yml b/install/fs/config.yml index 6cea3f13..e7325562 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -10,9 +10,9 @@ server: next_port: 3002 internal_hostname: "pangolin" secure_cookies: true - session_cookie_name: "p_session" - resource_session_cookie_name: "p_resource_session" + session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + resource_session_request_param: "p_session_request" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] diff --git a/package.json b/package.json index cc28ab10..446e3d37 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "moment": "2.30.1", "next": "15.1.3", "next-themes": "0.4.4", + "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "6.9.16", "oslo": "1.2.1", diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index ac91fc1f..29c54eee 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -3,7 +3,13 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -import { Session, sessions, User, users } from "@server/db/schema"; +import { + resourceSessions, + Session, + sessions, + User, + users +} from "@server/db/schema"; import db from "@server/db"; import { eq } from "drizzle-orm"; import config from "@server/lib/config"; @@ -13,9 +19,14 @@ import logger from "@server/logger"; export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; -export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; +export const SESSION_COOKIE_EXPIRES = + 1000 * + 60 * + 60 * + config.getRawConfig().server.dashboard_session_length_hours; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; -export const COOKIE_DOMAIN = "." + config.getBaseDomain(); +export const COOKIE_DOMAIN = + "." + new URL(config.getRawConfig().app.dashboard_url).hostname; export function generateSessionToken(): string { const bytes = new Uint8Array(20); @@ -65,12 +76,21 @@ export async function validateSessionToken( session.expiresAt = new Date( Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); - await db - .update(sessions) - .set({ - expiresAt: session.expiresAt - }) - .where(eq(sessions.sessionId, session.sessionId)); + await db.transaction(async (trx) => { + await trx + .update(sessions) + .set({ + expiresAt: session.expiresAt + }) + .where(eq(sessions.sessionId, session.sessionId)); + + await trx + .update(resourceSessions) + .set({ + expiresAt: session.expiresAt + }) + .where(eq(resourceSessions.userSessionId, session.sessionId)); + }); } return { session, user }; } @@ -90,9 +110,9 @@ export function serializeSessionCookie( if (isSecure) { logger.debug("Setting cookie for secure origin"); if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; } else { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`; } } else { return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index e9b32416..e9dd9b96 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -6,19 +6,20 @@ import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; export const SESSION_COOKIE_NAME = - config.getRawConfig().server.resource_session_cookie_name; -export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; + config.getRawConfig().server.session_cookie_name; +export const SESSION_COOKIE_EXPIRES = + 1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; -export const COOKIE_DOMAIN = "." + config.getBaseDomain(); export async function createResourceSession(opts: { token: string; resourceId: number; - passwordId?: number; - pincodeId?: number; - whitelistId?: number; - accessTokenId?: string; - usedOtp?: boolean; + isRequestToken?: boolean; + passwordId?: number | null; + pincodeId?: number | null; + userSessionId?: string | null; + whitelistId?: number | null; + accessTokenId?: string | null; doNotExtend?: boolean; expiresAt?: number | null; sessionLength?: number | null; @@ -27,7 +28,8 @@ export async function createResourceSession(opts: { !opts.passwordId && !opts.pincodeId && !opts.whitelistId && - !opts.accessTokenId + !opts.accessTokenId && + !opts.userSessionId ) { throw new Error("Auth method must be provided"); } @@ -47,7 +49,9 @@ export async function createResourceSession(opts: { pincodeId: opts.pincodeId || null, whitelistId: opts.whitelistId || null, doNotExtend: opts.doNotExtend || false, - accessTokenId: opts.accessTokenId || null + accessTokenId: opts.accessTokenId || null, + isRequestToken: opts.isRequestToken || false, + userSessionId: opts.userSessionId || null }; await db.insert(resourceSessions).values(session); @@ -162,22 +166,25 @@ export async function invalidateAllSessions( export function serializeResourceSessionCookie( cookieName: string, - token: string + domain: string, + token: string, + isHttp: boolean = false ): string { - if (SECURE_COOKIES) { - return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + if (SECURE_COOKIES && !isHttp) { + return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`; } else { - return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`; } } export function createBlankResourceSessionTokenCookie( - cookieName: string + cookieName: string, + domain: string ): string { if (SECURE_COOKIES) { - return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`; } else { - return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`; } } diff --git a/server/db/schema.ts b/server/db/schema.ts index fd9956b5..acdda169 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -313,6 +313,10 @@ export const resourceSessions = sqliteTable("resourceSessions", { doNotExtend: integer("doNotExtend", { mode: "boolean" }) .notNull() .default(false), + isRequestToken: integer("isRequestToken", { mode: "boolean" }), + userSessionId: text("userSessionId").references(() => sessions.sessionId, { + onDelete: "cascade" + }), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { diff --git a/server/index.ts b/server/index.ts index 364db07a..fd56f4eb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; -import { User, UserOrg } from "./db/schema"; +import { Session, User, UserOrg } from "./db/schema"; async function startServers() { await runSetupFunctions(); @@ -24,6 +24,7 @@ declare global { namespace Express { interface Request { user?: User; + session?: Session; userOrg?: UserOrg; userOrgRoleId?: number; userOrgId?: string; diff --git a/server/lib/config.ts b/server/lib/config.ts index 53dac62e..2c1ef7f0 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -61,8 +61,20 @@ const configSchema = z.object({ internal_hostname: z.string().transform((url) => url.toLowerCase()), secure_cookies: z.boolean(), session_cookie_name: z.string(), - resource_session_cookie_name: z.string(), resource_access_token_param: z.string(), + resource_session_request_param: z.string(), + dashboard_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + resource_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), cors: z .object({ origins: z.array(z.string()).optional(), @@ -241,8 +253,6 @@ export class Config { : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; - process.env.RESOURCE_SESSION_COOKIE_NAME = - parsedConfig.data.server.resource_session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags ?.disable_signup_without_invite @@ -254,6 +264,8 @@ export class Config { : "false"; process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; + process.env.RESOURCE_SESSION_REQUEST_PARAM = + parsedConfig.data.server.resource_session_request_param; this.rawConfig = parsedConfig.data; } diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index 3b466767..e9e51a31 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -5,18 +5,17 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { createBlankSessionTokenCookie, - invalidateSession, - SESSION_COOKIE_NAME + invalidateSession } from "@server/auth/sessions/app"; +import { verifySession } from "@server/auth/sessions/verifySession"; export async function logout( req: Request, res: Response, next: NextFunction ): Promise { - const sessionId = req.cookies[SESSION_COOKIE_NAME]; - - if (!sessionId) { + const { user, session } = await verifySession(req); + if (!user || !session) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -26,7 +25,7 @@ export async function logout( } try { - await invalidateSession(sessionId); + await invalidateSession(session.sessionId); const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts new file mode 100644 index 00000000..eaf47e6a --- /dev/null +++ b/server/routers/badger/exchangeSession.ts @@ -0,0 +1,175 @@ +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { resourceAccessToken, resources, sessions } from "@server/db/schema"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import { + createResourceSession, + serializeResourceSessionCookie, + validateResourceSessionToken +} from "@server/auth/sessions/resource"; +import { generateSessionToken } from "@server/auth"; +import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; +import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; +import config from "@server/lib/config"; +import { response } from "@server/lib"; + +const exchangeSessionBodySchema = z.object({ + requestToken: z.string(), + host: z.string() +}); + +export type ExchangeSessionBodySchema = z.infer< + typeof exchangeSessionBodySchema +>; + +export type ExchangeSessionResponse = { + valid: boolean; + cookie?: string; +}; + +export async function exchangeSession( + req: Request, + res: Response, + next: NextFunction +): Promise { + logger.debug("Exchange session: Badger sent", req.body); + + const parsedBody = exchangeSessionBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { requestToken, host } = parsedBody.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, host)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with host ${host} not found` + ) + ); + } + + const { resourceSession: requestSession } = + await validateResourceSessionToken( + requestToken, + resource.resourceId + ); + + if (!requestSession) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") + ); + } + + if (!requestSession.isRequestToken) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") + ); + } + + await db.delete(sessions).where(eq(sessions.sessionId, requestToken)); + + const token = generateSessionToken(); + + if (requestSession.userSessionId) { + const [res] = await db + .select() + .from(sessions) + .where(eq(sessions.sessionId, requestSession.userSessionId)) + .limit(1); + if (res) { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + userSessionId: requestSession.userSessionId, + doNotExtend: false, + expiresAt: res.expiresAt, + sessionLength: SESSION_COOKIE_EXPIRES + }); + } + } else if (requestSession.accessTokenId) { + const [res] = await db + .select() + .from(resourceAccessToken) + .where( + eq( + resourceAccessToken.accessTokenId, + requestSession.accessTokenId + ) + ) + .limit(1); + if (res) { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + accessTokenId: requestSession.accessTokenId, + doNotExtend: true, + expiresAt: res.expiresAt, + sessionLength: res.sessionLength + }); + } + } else { + await createResourceSession({ + token, + resourceId: resource.resourceId, + isRequestToken: false, + passwordId: requestSession.passwordId, + pincodeId: requestSession.pincodeId, + userSessionId: requestSession.userSessionId, + whitelistId: requestSession.whitelistId, + accessTokenId: requestSession.accessTokenId, + doNotExtend: false, + expiresAt: new Date( + Date.now() + SESSION_COOKIE_EXPIRES + ).getTime(), + sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES + }); + } + + const cookieName = `${config.getRawConfig().server.session_cookie_name}`; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain, + token, + !resource.ssl + ); + + logger.debug(JSON.stringify("Exchange cookie: " + cookie)); + return response(res, { + data: { valid: true, cookie }, + success: true, + error: false, + message: "Session exchanged successfully", + status: HttpCode.OK + }); + } catch (e) { + console.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to exchange session" + ) + ); + } +} diff --git a/server/routers/badger/index.ts b/server/routers/badger/index.ts index 7af4684a..09f3a79b 100644 --- a/server/routers/badger/index.ts +++ b/server/routers/badger/index.ts @@ -1 +1,2 @@ export * from "./verifySession"; +export * from "./exchangeSession"; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0593e3b4..ae6d2edc 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -4,17 +4,17 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { response } from "@server/lib/response"; -import { validateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; import { ResourceAccessToken, - resourceAccessToken, + ResourcePassword, resourcePassword, + ResourcePincode, resourcePincode, resources, - resourceWhitelist, - User, - userOrgs + sessions, + userOrgs, + users } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import config from "@server/lib/config"; @@ -27,6 +27,12 @@ import { Resource, roleResources, userResources } from "@server/db/schema"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { generateSessionToken } from "@server/auth"; +import NodeCache from "node-cache"; + +// We'll see if this speeds anything up +const cache = new NodeCache({ + stdTTL: 5 // seconds +}); const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string()).optional(), @@ -53,7 +59,7 @@ export async function verifyResourceSession( res: Response, next: NextFunction ): Promise { - logger.debug("Badger sent", req.body); // remove when done testing + logger.debug("Verify session: Badger sent", req.body); // remove when done testing const parsedBody = verifyResourceSessionSchema.safeParse(req.body); @@ -67,26 +73,52 @@ export async function verifyResourceSession( } try { - const { sessions, host, originalRequestURL, accessToken: token } = - parsedBody.data; + const { + sessions, + host, + originalRequestURL, + accessToken: token + } = parsedBody.data; - const [result] = await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .where(eq(resources.fullDomain, host)) - .limit(1); + const resourceCacheKey = `resource:${host}`; + let resourceData: + | { + resource: Resource | null; + pincode: ResourcePincode | null; + password: ResourcePassword | null; + } + | undefined = cache.get(resourceCacheKey); - const resource = result?.resources; - const pincode = result?.resourcePincode; - const password = result?.resourcePassword; + if (!resourceData) { + const [result] = await db + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.fullDomain, host)) + .limit(1); + + if (!result) { + logger.debug("Resource not found", host); + return notAllowed(res); + } + + resourceData = { + resource: result.resources, + pincode: result.resourcePincode, + password: result.resourcePassword + }; + + cache.set(resourceCacheKey, resourceData); + } + + const { resource, pincode, password } = resourceData; if (!resource) { logger.debug("Resource not found", host); @@ -145,37 +177,31 @@ export async function verifyResourceSession( return notAllowed(res); } - const sessionToken = - sessions[config.getRawConfig().server.session_cookie_name]; - - // check for unified login - if (sso && sessionToken) { - const { session, user } = await validateSessionToken(sessionToken); - if (session && user) { - const isAllowed = await isUserAllowedToAccessResource( - user, - resource - ); - - if (isAllowed) { - logger.debug( - "Resource allowed because user session is valid" - ); - return allowed(res); - } - } - } - const resourceSessionToken = sessions[ - `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}` + `${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}` ]; if (resourceSessionToken) { - const { resourceSession } = await validateResourceSessionToken( - resourceSessionToken, - resource.resourceId - ); + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: any = cache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + + resourceSession = result?.resourceSession; + cache.set(sessionCacheKey, resourceSession); + } + + if (resourceSession?.isRequestToken) { + logger.debug( + "Resource not allowed because session is a temporary request token" + ); + return notAllowed(res); + } if (resourceSession) { if (pincode && resourceSession.pincodeId) { @@ -208,6 +234,29 @@ export async function verifyResourceSession( ); return allowed(res); } + + if (resourceSession.userSessionId && sso) { + const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`; + + let isAllowed: boolean | undefined = + cache.get(userAccessCacheKey); + + if (isAllowed === undefined) { + isAllowed = await isUserAllowedToAccessResource( + resourceSession.userSessionId, + resource + ); + + cache.set(userAccessCacheKey, isAllowed); + } + + if (isAllowed) { + logger.debug( + "Resource allowed because user session is valid" + ); + return allowed(res); + } + } } } @@ -272,10 +321,15 @@ async function createAccessTokenSession( expiresAt: tokenItem.expiresAt, doNotExtend: tokenItem.expiresAt ? true : false }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); + const cookieName = `${config.getRawConfig().server.session_cookie_name}`; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain, + token, + !resource.ssl + ); res.appendHeader("Set-Cookie", cookie); - logger.debug("Access token is valid, creating new session") + logger.debug("Access token is valid, creating new session"); return response(res, { data: { valid: true }, success: true, @@ -286,9 +340,22 @@ async function createAccessTokenSession( } async function isUserAllowedToAccessResource( - user: User, + userSessionId: string, resource: Resource ): Promise { + const [res] = await db + .select() + .from(sessions) + .leftJoin(users, eq(users.userId, sessions.userId)) + .where(eq(sessions.sessionId, userSessionId)); + + const user = res.user; + const session = res.session; + + if (!user || !session) { + return false; + } + if ( config.getRawConfig().flags?.require_email_verification && !user.emailVerified diff --git a/server/routers/internal.ts b/server/routers/internal.ts index fb0e1809..30f7fd6d 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -3,7 +3,9 @@ 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"; // Root routes const internalRouter = Router(); @@ -15,7 +17,14 @@ internalRouter.get("/", (_, res) => { internalRouter.get("/traefik-config", traefik.traefikConfigProvider); internalRouter.get( "/resource-session/:resourceId/:token", - auth.checkResourceSession, + auth.checkResourceSession +); + +internalRouter.post( + `/resource/:resourceId/get-exchange-token`, + verifySessionUserMiddleware, + verifyResourceAccess, + resource.getExchangeToken ); // Gerbil routes @@ -30,5 +39,6 @@ const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); +badgerRouter.post("/exchange-session", badger.exchangeSession); export default internalRouter; diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index a4340f77..add5f275 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,18 +1,16 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { resourceAccessToken, resources } from "@server/db/schema"; +import { resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession, - serializeResourceSessionCookie } from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; @@ -108,13 +106,11 @@ export async function authWithAccessToken( resourceId, token, accessTokenId: tokenItem.accessTokenId, - sessionLength: tokenItem.sessionLength, - expiresAt: tokenItem.expiresAt, - doNotExtend: tokenItem.expiresAt ? true : false + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index dc7c41a9..1aa5d632 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -11,9 +11,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession, - serializeResourceSessionCookie } from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; @@ -120,11 +118,12 @@ export async function authWithPassword( await createResourceSession({ resourceId, token, - passwordId: definedPassword.passwordId + passwordId: definedPassword.passwordId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 97a0b13c..6d83ba22 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,28 +1,21 @@ -import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; import { orgs, - resourceOtp, resourcePincode, resources, - resourceWhitelist } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession, - serializeResourceSessionCookie } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import config from "@server/lib/config"; -import { AuthWithPasswordResponse } from "./authWithPassword"; -import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import { verifyPassword } from "@server/auth/password"; export const authWithPincodeBodySchema = z @@ -128,11 +121,12 @@ export async function authWithPincode( await createResourceSession({ resourceId, token, - pincodeId: definedPincode.pincodeId + pincodeId: definedPincode.pincodeId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 54c43f62..c58c7efd 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -3,7 +3,6 @@ import db from "@server/db"; import { orgs, resourceOtp, - resourcePassword, resources, resourceWhitelist } from "@server/db/schema"; @@ -16,9 +15,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession, - serializeResourceSessionCookie } from "@server/auth/sessions/resource"; -import config from "@server/lib/config"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; @@ -178,11 +175,12 @@ export async function authWithWhitelist( await createResourceSession({ resourceId, token, - whitelistId: whitelistedEmail.whitelistId + whitelistId: whitelistedEmail.whitelistId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true }); - const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`; - const cookie = serializeResourceSessionCookie(cookieName, token); - res.appendHeader("Set-Cookie", cookie); return response(res, { data: { diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts new file mode 100644 index 00000000..f80a0833 --- /dev/null +++ b/server/routers/resource/getExchangeToken.ts @@ -0,0 +1,109 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import { createResourceSession } from "@server/auth/sessions/resource"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import config from "@server/lib/config"; +import { + encodeHexLowerCase +} from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { response } from "@server/lib"; + +const getExchangeTokenParams = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +export type GetExchangeTokenResponse = { + requestToken: string; +}; + +export async function getExchangeToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getExchangeTokenParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const resource = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (resource.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const ssoSession = + req.cookies[config.getRawConfig().server.session_cookie_name]; + if (!ssoSession) { + logger.debug(ssoSession); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Missing SSO session cookie" + ) + ); + } + + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(ssoSession)) + ); + + const token = generateSessionToken(); + await createResourceSession({ + resourceId, + token, + userSessionId: sessionId, + isRequestToken: true, + expiresAt: Date.now() + 1000 * 30, // 30 seconds + sessionLength: 1000 * 30, + doNotExtend: true + }); + + logger.debug("Request token created successfully"); + + return response(res, { + data: { + requestToken: token + }, + success: true, + error: false, + message: "Request token created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 7dbee1bf..ca06dfc3 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -16,3 +16,4 @@ export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; +export * from "./getExchangeToken"; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 67b68123..6a0e7301 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -111,6 +111,10 @@ export async function updateResource( ); } + if (resource[0].resources.ssl !== updatedResource[0].ssl) { + // invalidate all sessions? + } + return response(res, { data: updatedResource[0], success: true, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 1153a532..bfc86405 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -39,7 +39,7 @@ export async function traefikConfigProvider( } const badgerMiddlewareName = "badger"; - const redirectMiddlewareName = "redirect-to-https"; + const redirectHttpsMiddlewareName = "redirect-to-https"; const http: any = { routers: {}, @@ -52,19 +52,18 @@ export async function traefikConfigProvider( "/api/v1", `http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`, ).href, - resourceSessionCookieName: - config.getRawConfig().server.resource_session_cookie_name, userSessionCookieName: config.getRawConfig().server.session_cookie_name, accessTokenQueryParam: config.getRawConfig().server.resource_access_token_param, + resourceSessionRequestParam: config.getRawConfig().server.resource_session_request_param }, }, }, - [redirectMiddlewareName]: { + [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" }, - }, + } }, }; for (const item of all) { @@ -120,10 +119,9 @@ export async function traefikConfigProvider( }; if (resource.ssl) { - // this is a redirect router; all it does is redirect to the https version if tls is enabled http.routers![routerName + "-redirect"] = { entryPoints: [config.getRawConfig().traefik.http_entrypoint], - middlewares: [redirectMiddlewareName], + middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: `Host(\`${fullDomain}\`)`, }; diff --git a/server/setup/scripts/1.0.0-beta9.ts b/server/setup/scripts/1.0.0-beta9.ts index e4f62659..9cccd554 100644 --- a/server/setup/scripts/1.0.0-beta9.ts +++ b/server/setup/scripts/1.0.0-beta9.ts @@ -7,7 +7,13 @@ import { userInvites, users } from "@server/db/schema"; +import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { sql } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; +import path from "path"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; export default async function migration() { console.log("Running setup script 1.0.0-beta.9..."); @@ -32,5 +38,96 @@ export default async function migration() { console.error(error); } + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + rawConfig.server.resource_session_request_param = "p_session_request"; + rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts + delete rawConfig.server.resource_session_cookie_name; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + } catch (e) { + console.log( + `Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + ); + throw e; + } + + try { + const traefikPath = path.join( + APP_PATH, + "traefik", + "traefik_config.yml" + ); + + const schema = z.object({ + experimental: z.object({ + plugins: z.object({ + badger: z.object({ + moduleName: z.string(), + version: z.string() + }) + }) + }) + }); + + const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); + const traefikConfig = yaml.load(traefikFileContents) as any; + + const parsedConfig = schema.safeParse(traefikConfig); + + if (!parsedConfig.success) { + throw new Error(fromZodError(parsedConfig.error).toString()); + } + + 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." + ); + } catch (e) { + console.log( + "We were unable to update the version of Badger in your Traefik configuration. Please update it manually." + ); + console.error(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);`); + }); + } catch (e) { + console.log( + "We were unable to add columns to the resourceSessions table." + ); + throw e; + } + console.log("Done."); } diff --git a/src/app/auth/resource/[resourceId]/AccessToken.tsx b/src/app/auth/resource/[resourceId]/AccessToken.tsx index 408a187b..b70d75cb 100644 --- a/src/app/auth/resource/[resourceId]/AccessToken.tsx +++ b/src/app/auth/resource/[resourceId]/AccessToken.tsx @@ -5,14 +5,12 @@ import { Button } from "@app/components/ui/button"; import { Card, CardContent, - CardFooter, CardHeader, CardTitle } from "@app/components/ui/card"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { AuthWithAccessTokenResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; -import { Loader2 } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; @@ -32,7 +30,17 @@ export default function AccessToken({ const [loading, setLoading] = useState(true); const [isValid, setIsValid] = useState(false); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + function appendRequestToken(url: string, token: string) { + const fullUrl = new URL(url); + fullUrl.searchParams.append( + env.server.resourceSessionRequestParam, + token + ); + return fullUrl.toString(); + } useEffect(() => { if (!accessTokenId || !accessToken) { @@ -51,7 +59,10 @@ export default function AccessToken({ if (res.data.data.session) { setIsValid(true); - window.location.href = redirectUrl; + window.location.href = appendRequestToken( + redirectUrl, + res.data.data.session + ); } } catch (e) { console.error("Error checking access token", e); diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index c23403a8..1aec64d9 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import * as z from "zod"; @@ -8,7 +8,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -30,9 +29,6 @@ import { Key, User, Send, - ArrowLeft, - ArrowRight, - Lock, AtSign } from "lucide-react"; import { @@ -47,10 +43,8 @@ import { AxiosResponse } from "axios"; import LoginForm from "@app/components/LoginForm"; import { AuthWithPasswordResponse, - AuthWithAccessTokenResponse, AuthWithWhitelistResponse } from "@server/routers/resource"; -import { redirect } from "next/dist/server/api-utils"; import ResourceAccessDenied from "./ResourceAccessDenied"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -118,7 +112,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle"); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const api = createApiClient({ env }); function getDefaultSelectedMethod() { if (props.methods.sso) { @@ -169,6 +165,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } }); + function appendRequestToken(url: string, token: string) { + const fullUrl = new URL(url); + fullUrl.searchParams.append( + env.server.resourceSessionRequestParam, + token + ); + return fullUrl.toString(); + } + const onWhitelistSubmit = (values: any) => { setLoadingLogin(true); api.post>( @@ -190,7 +195,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -212,7 +217,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPincodeError(null); const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -237,7 +242,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setPasswordError(null); const session = res.data.data.session; if (session) { - window.location.href = props.redirect; + window.location.href = appendRequestToken(props.redirect, session); } }) .catch((e) => { @@ -619,16 +624,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { - {/* {activeTab === "sso" && ( -
-

- Don't have an account?{" "} - - Sign up - -

-
- )} */} ) : ( diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 49041a0d..cc576a03 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -1,7 +1,6 @@ import { - AuthWithAccessTokenResponse, GetResourceAuthInfoResponse, - GetResourceResponse + GetExchangeTokenResponse } from "@server/routers/resource"; import ResourceAuthPortal from "./ResourceAuthPortal"; import { internal, priv } from "@app/lib/api"; @@ -12,9 +11,6 @@ import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import ResourceNotFound from "./ResourceNotFound"; import ResourceAccessDenied from "./ResourceAccessDenied"; -import { cookies } from "next/headers"; -import { CheckResourceSessionResponse } from "@server/routers/auth"; -import AccessTokenInvalid from "./AccessToken"; import AccessToken from "./AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; @@ -48,7 +44,7 @@ export default async function ResourceAuthPage(props: { // TODO: fix this return (
- {/* @ts-ignore */} + {/* @ts-ignore */}
); @@ -83,49 +79,41 @@ export default async function ResourceAuthPage(props: { ); } - const allCookies = await cookies(); - const cookieName = - env.server.resourceSessionCookieName + `_${params.resourceId}`; - const sessionId = allCookies.get(cookieName)?.value ?? null; - - if (sessionId) { - let doRedirect = false; - try { - const res = await priv.get< - AxiosResponse - >(`/resource-session/${params.resourceId}/${sessionId}`); - - if (res && res.data.data.valid) { - doRedirect = true; - } - } catch (e) {} - - if (doRedirect) { - redirect(redirectUrl); - } - } - if (!hasAuth) { // no authentication so always go straight to the resource redirect(redirectUrl); } + + // convert the dashboard token into a resource session token let userIsUnauthorized = false; if (user && authInfo.sso) { - let doRedirect = false; + let redirectToUrl: string | undefined; try { - const res = await internal.get>( - `/resource/${params.resourceId}`, + const res = await priv.post< + AxiosResponse + >( + `/resource/${params.resourceId}/get-exchange-token`, + {}, await authCookieHeader() ); - doRedirect = true; + if (res.data.data.requestToken) { + const paramName = env.server.resourceSessionRequestParam; + // append the param with the token to the redirect url + const fullUrl = new URL(redirectUrl); + fullUrl.searchParams.append( + paramName, + res.data.data.requestToken + ); + redirectToUrl = fullUrl.toString(); + } } catch (e) { userIsUnauthorized = true; } - if (doRedirect) { - redirect(redirectUrl); + if (redirectToUrl) { + redirect(redirectToUrl); } } diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index d335d703..564ba0bc 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -6,8 +6,8 @@ export function pullEnv(): Env { nextPort: process.env.NEXT_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string, - resourceSessionCookieName: process.env.RESOURCE_SESSION_COOKIE_NAME as string, - resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string + resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string, + resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string }, app: { environment: process.env.ENVIRONMENT as string, diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 559bb531..9a2e5b93 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -7,8 +7,8 @@ export type Env = { externalPort: string; nextPort: string; sessionCookieName: string; - resourceSessionCookieName: string; resourceAccessTokenParam: string; + resourceSessionRequestParam: string; }, email: { emailEnabled: boolean; From 61b34c8b16550d06dfe7e193c08716e62dd2f32d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 26 Jan 2025 18:12:30 -0500 Subject: [PATCH 20/56] allow wildcard emails in email whitelist --- server/routers/resource/authWithWhitelist.ts | 52 ++++++++++++++----- .../routers/resource/setResourceWhitelist.ts | 12 ++++- .../[resourceId]/authentication/page.tsx | 18 ++++++- src/components/ui/info-popup.tsx | 38 ++++++++++++++ 4 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/info-popup.tsx diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index c58c7efd..f97b3035 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -13,9 +13,7 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; @@ -90,20 +88,48 @@ export async function authWithWhitelist( .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .limit(1); - const resource = result?.resources; - const org = result?.orgs; - const whitelistedEmail = result?.resourceWhitelist; + let resource = result?.resources; + let org = result?.orgs; + let whitelistedEmail = result?.resourceWhitelist; if (!whitelistedEmail) { - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - createHttpError( - HttpCode.BAD_REQUEST, - "Email is not whitelisted" + // if email is not found, check for wildcard email + const wildcard = "*@" + email.split("@")[1]; + + logger.debug("Checking for wildcard email: " + wildcard) + + const [result] = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, wildcard) ) ) - ); + .leftJoin( + resources, + eq(resources.resourceId, resourceWhitelist.resourceId) + ) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) + .limit(1); + + resource = result?.resources; + org = result?.orgs; + whitelistedEmail = result?.resourceWhitelist; + + // if wildcard is still not found, return unauthorized + if (!whitelistedEmail) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Email is not whitelisted" + ) + ) + ); + } } if (!org) { diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 8931d4ff..d60d079d 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -12,7 +12,17 @@ import { and, eq } from "drizzle-orm"; const setResourceWhitelistBodySchema = z .object({ emails: z - .array(z.string().email()) + .array( + z + .string() + .email() + .or( + z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { + message: + "Invalid email address. Wildcard (*) must be the entire local part." + }) + ) + ) .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index c59806b5..64464d60 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -48,6 +48,7 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; +import { InfoPopup } from "@app/components/ui/info-popup"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -665,10 +666,12 @@ export default function ResourceAuthenticationPage() { render={({ field }) => ( - Whitelisted Emails + - {/* @ts-ignore */} {/* @ts-ignore */} + {text} + + + + + +

{info}

+
+
+ + ); +} From fdb1ab4bd9f31f8008fbf0885664e5beb365efdd Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 27 Jan 2025 19:59:52 -0500 Subject: [PATCH 21/56] allow setting secure for smtp in config --- server/emails/index.ts | 23 +++++++++-------------- server/lib/config.ts | 9 +++++---- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/server/emails/index.ts b/server/emails/index.ts index 10ee4933..156d491b 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -6,26 +6,21 @@ import logger from "@server/logger"; function createEmailClient() { const emailConfig = config.getRawConfig().email; -if ( - !emailConfig?.smtp_host || - !emailConfig?.smtp_pass || - !emailConfig?.smtp_port || - !emailConfig?.smtp_user -) { - logger.warn( - "Email SMTP configuration is missing. Emails will not be sent.", - ); - return; -} + if (!emailConfig || !emailConfig.no_reply) { + logger.warn( + "Email SMTP configuration is missing. Emails will not be sent." + ); + return; + } return nodemailer.createTransport({ host: emailConfig.smtp_host, port: emailConfig.smtp_port, - secure: false, + secure: emailConfig.smtp_secure || false, auth: { user: emailConfig.smtp_user, - pass: emailConfig.smtp_pass, - }, + pass: emailConfig.smtp_pass + } }); } diff --git a/server/lib/config.ts b/server/lib/config.ts index 2c1ef7f0..e44cff5c 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -122,10 +122,11 @@ const configSchema = z.object({ }), email: z .object({ - smtp_host: z.string(), - smtp_port: portSchema, - smtp_user: z.string(), - smtp_pass: z.string(), + smtp_host: z.string().optional(), + smtp_port: portSchema.optional(), + smtp_user: z.string().optional(), + smtp_pass: z.string().optional(), + smtp_secure: z.boolean().optional(), no_reply: z.string().email() }) .optional(), From 0bd8217d9ef6bc685d46480aefca7982a2a34a78 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 27 Jan 2025 22:43:32 -0500 Subject: [PATCH 22/56] add failed auth logging --- server/lib/config.ts | 3 +- server/middlewares/verifyUser.ts | 7 +++++ server/routers/auth/disable2fa.ts | 5 ++++ server/routers/auth/login.ts | 15 ++++++++++ server/routers/auth/logout.ts | 6 ++++ server/routers/auth/resetPassword.ts | 15 ++++++++++ server/routers/auth/signup.ts | 20 ++++++++++++- server/routers/auth/verifyEmail.ts | 5 ++++ server/routers/auth/verifyTotp.ts | 5 ++++ server/routers/badger/exchangeSession.ts | 17 +++++++++-- server/routers/badger/verifySession.ts | 30 ++++++++++++++++++- server/routers/newt/getToken.ts | 21 ++++++++++--- .../routers/resource/authWithAccessToken.ts | 10 +++++-- server/routers/resource/authWithPassword.ts | 12 +++++--- server/routers/resource/authWithPincode.ts | 16 +++++----- server/routers/resource/authWithWhitelist.ts | 13 +++++++- 16 files changed, 175 insertions(+), 25 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index e44cff5c..9476b505 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -40,7 +40,8 @@ const configSchema = z.object({ .pipe(hostnameSchema) .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), - save_logs: z.boolean() + save_logs: z.boolean(), + log_failed_attempts: z.boolean().optional(), }), server: z.object({ external_port: portSchema diff --git a/server/middlewares/verifyUser.ts b/server/middlewares/verifyUser.ts index 0f52f2e3..72680352 100644 --- a/server/middlewares/verifyUser.ts +++ b/server/middlewares/verifyUser.ts @@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; +import logger from "@server/logger"; export const verifySessionUserMiddleware = async ( req: any, @@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async ( ) => { const { session, user } = await verifySession(req); if (!session || !user) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info(`User session not found. IP: ${req.ip}.`); + } return next(unauthorized()); } @@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async ( .where(eq(users.userId, user.userId)); if (!existingUser || !existingUser[0]) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info(`User session not found. IP: ${req.ip}.`); + } return next( createHttpError(HttpCode.BAD_REQUEST, "User does not exist") ); diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index e27d75df..b93e9cc3 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -79,6 +79,11 @@ export async function disable2fa( ); if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 7ee0d927..09bd9661 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -71,6 +71,11 @@ export async function login( .from(users) .where(eq(users.email, email)); if (!existingUserRes || !existingUserRes.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -86,6 +91,11 @@ export async function login( existingUser.passwordHash ); if (!validPassword) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -112,6 +122,11 @@ export async function login( ); if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index e9e51a31..a2e0a7f1 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -8,6 +8,7 @@ import { invalidateSession } from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; +import config from "@server/lib/config"; export async function logout( req: Request, @@ -16,6 +17,11 @@ export async function logout( ): Promise { const { user, session } = await verifySession(req); if (!user || !session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Log out failed because missing or invalid session. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index 45c8652b..d112e98b 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -60,6 +60,11 @@ export async function resetPassword( .where(eq(passwordResetTokens.email, email)); if (!resetRequest || !resetRequest.length) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -109,6 +114,11 @@ export async function resetPassword( ); if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -124,6 +134,11 @@ export async function resetPassword( ); if (!isTokenValid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 2a4bb127..4bb5394e 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; export const signupBodySchema = z.object({ - email: z.string().email().transform((v) => v.toLowerCase()), + email: z + .string() + .email() + .transform((v) => v.toLowerCase()), password: passwordSchema, inviteToken: z.string().optional(), inviteId: z.string().optional() @@ -60,6 +63,11 @@ export async function signup( if (config.getRawConfig().flags?.disable_signup_without_invite) { if (!inviteToken || !inviteId) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Signup blocked without invite. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -84,6 +92,11 @@ export async function signup( } if (existingInvite.email !== email) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, @@ -185,6 +198,11 @@ export async function signup( }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index 45ef4230..e189e9a6 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -75,6 +75,11 @@ export async function verifyEmail( .where(eq(users.userId, user.userId)); }); } else { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index f7b8eb38..36bbf348 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -96,6 +96,11 @@ export async function verifyTotp( } if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index eaf47e6a..1af960aa 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -20,7 +20,8 @@ import { response } from "@server/lib"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), - host: z.string() + host: z.string(), + requestIp: z.string().optional() }); export type ExchangeSessionBodySchema = z.infer< @@ -51,7 +52,9 @@ export async function exchangeSession( } try { - const { requestToken, host } = parsedBody.data; + const { requestToken, host, requestIp } = parsedBody.data; + + const clientIp = requestIp?.split(":")[0]; const [resource] = await db .select() @@ -75,12 +78,22 @@ export async function exchangeSession( ); if (!requestSession) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") ); } if (!requestSession.isRequestToken) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") ); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ae6d2edc..a71e6b3c 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -42,7 +42,8 @@ const verifyResourceSessionSchema = z.object({ path: z.string(), method: z.string(), accessToken: z.string().optional(), - tls: z.boolean() + tls: z.boolean(), + requestIp: z.string().optional() }); export type VerifyResourceSessionSchema = z.infer< @@ -77,9 +78,12 @@ export async function verifyResourceSession( sessions, host, originalRequestURL, + requestIp, accessToken: token } = parsedBody.data; + const clientIp = requestIp?.split(":")[0]; + const resourceCacheKey = `resource:${host}`; let resourceData: | { @@ -160,6 +164,14 @@ export async function verifyResourceSession( logger.debug("Access token invalid: " + error); } + if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } + } + if (valid && tokenItem) { validAccessToken = tokenItem; @@ -174,6 +186,11 @@ export async function verifyResourceSession( } if (!sessions) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return notAllowed(res); } @@ -200,6 +217,11 @@ export async function verifyResourceSession( logger.debug( "Resource not allowed because session is a temporary request token" ); + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return notAllowed(res); } @@ -271,6 +293,12 @@ export async function verifyResourceSession( } logger.debug("No more auth to check, resource not allowed"); + + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` + ); + } return notAllowed(res, redirectUrl); } catch (e) { console.error(e); diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getToken.ts index f13cbac0..e6ae0cd6 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getToken.ts @@ -1,6 +1,4 @@ -import { - generateSessionToken, -} from "@server/auth/sessions/app"; +import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; import { newts } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; @@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { + createNewtSession, + validateNewtSessionToken +} from "@server/auth/sessions/newt"; import { verifyPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import config from "@server/lib/config"; export const newtGetTokenBodySchema = z.object({ newtId: z.string(), @@ -43,6 +46,11 @@ export async function getToken( if (token) { const { session, newt } = await validateNewtSessionToken(token); if (session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.` + ); + } return response(res, { data: null, success: true, @@ -73,6 +81,11 @@ export async function getToken( existingNewt.secretHash ); if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index add5f275..03dac735 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -8,11 +8,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; +import config from "@server/lib/config"; const authWithAccessTokenBodySchema = z .object({ @@ -84,6 +83,11 @@ export async function authWithAccessToken( }); if (!valid) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.UNAUTHORIZED, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 1aa5d632..a69d42b6 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -9,11 +9,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; +import config from "@server/lib/config"; export const authWithPasswordBodySchema = z .object({ @@ -82,7 +81,7 @@ export async function authWithPassword( if (!org) { return next( - createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + createHttpError(HttpCode.BAD_REQUEST, "Org does not exist") ); } @@ -109,6 +108,11 @@ export async function authWithPassword( definedPassword.passwordHash ); if (!validPassword) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 6d83ba22..c648e36c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,10 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import db from "@server/db"; -import { - orgs, - resourcePincode, - resources, -} from "@server/db/schema"; +import { orgs, resourcePincode, resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -12,11 +8,10 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { - createResourceSession, -} from "@server/auth/sessions/resource"; +import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; +import config from "@server/lib/config"; export const authWithPincodeBodySchema = z .object({ @@ -112,6 +107,11 @@ export async function authWithPincode( definedPincode.pincodeHash ); if (!validPincode) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index f97b3035..dae36b24 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -16,6 +16,7 @@ import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; +import config from "@server/lib/config"; const authWithWhitelistBodySchema = z .object({ @@ -96,7 +97,7 @@ export async function authWithWhitelist( // if email is not found, check for wildcard email const wildcard = "*@" + email.split("@")[1]; - logger.debug("Checking for wildcard email: " + wildcard) + logger.debug("Checking for wildcard email: " + wildcard); const [result] = await db .select() @@ -120,6 +121,11 @@ export async function authWithWhitelist( // if wildcard is still not found, return unauthorized if (!whitelistedEmail) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Email is not whitelisted. Resource ID: ${resource?.resourceId}. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -151,6 +157,11 @@ export async function authWithWhitelist( otp ); if (!isValidCode) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.` + ); + } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") ); From 472b0d70860a1b37f105e78d3e9b6dd5439bb764 Mon Sep 17 00:00:00 2001 From: Kamil <32385799+nkkfs@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:34:22 +0100 Subject: [PATCH 23/56] Create pl.md --- internationalization/pl.md | 267 +++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 internationalization/pl.md diff --git a/internationalization/pl.md b/internationalization/pl.md new file mode 100644 index 00000000..4414a908 --- /dev/null +++ b/internationalization/pl.md @@ -0,0 +1,267 @@ +## Login site + +| EN | PL | Notes | +| --------------------- | ------------------------------ | ----------- | +| Welcome to Pangolin | Witaj w Pangolin | | +| Log in to get started | Zaloguj się, aby rozpocząć
| | +| Email | Email | | +| Enter your email | Wprowadź swój adres e-mail
| placeholder | +| Password | Hasło | | +| Enter your password | Wprowadź swoje hasło | placeholder | +| Forgot your password? | Nie pamiętasz hasła? | | +| Log in | Zaloguj | | + +# Ogranization site after successful login + + +| EN | PL | Notes | +| ----------------------------------------- | ------------------------------------------ | ----- | +| Welcome to Pangolin | Witaj w Pangolin | | +| You're a member of {number} organization. | Jesteś użytkownikiem {number} organizacji. | | + +## Shared Header, Navbar and Footer +##### Header + +| EN | PL | Notes | +| ------------------- | ------------------ | ----- | +| Documentation | Dokumentacja | | +| Support | Wsparcie | | +| Organization {name} | Organizacja {name} | | +##### Organization selector + +| EN | PL | Notes | +| ---------------- | ---------------- | ----- | +| Search… | Szukaj… | | +| Create | Utwórz | | +| New Organization | Nowa organizacja | | +| Organizations | Organizacje | | + +##### Navbar + +| EN | PL | Notes | +| --------------- | ---------------------- | ----- | +| Sites | Witryny | | +| Resources | Zasoby | | +| User & Roles | Użytkownicy i Role | | +| Shareable Links | Łącza do udostępniania | | +| General | Ogólne | | +##### Footer +| EN | PL | | +| ------------------------- | -------------------------- | -------------- | +| Page {number} of {number} | Strona {number} z {number} | | +| Rows per page | Wierszy na stronę | | +| Pangolin | Pangolin | bottom of site | +| Built by Fossorial | Stworzone przez Fossorial | bottom of site | +| Open Source | Open source | bottom of site | +| Documentation | Dokumentacja | bottom of site | +| {version} | {version} | bottom of site | +## Main “Sites” +##### “Hero” section + +| EN | PL | Notes | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | +| Newt (Recommended) | Newt (zalecane) | | +| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Aby zapewnić najlepsze doświadczenie użytkownika, korzystaj z Newt. Wykorzystuje on technologię WireGuard w tle i pozwala na dostęp do Twoich prywatnych zasobów za pomocą ich adresu LAN w prywatnej sieci bezpośrednio z poziomu pulpitu nawigacyjnego Pangolin. | | +| Runs in Docker | Działa w Dockerze | | +| Runs in shell on macOS, Linux, and Windows | Działa w powłoce na systemach macOS, Linux i Windows | | +| Install Newt | Zainstaluj Newt | | +| Podstawowy WireGuard
| Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał uzyskiwać dostęp do swoich wewnętrznych zasobów za pomocą adresu IP równorzędnego | | +| Compatible with all WireGuard clients
| Kompatybilny ze wszystkimi klientami WireGuard
| | +| Manual configuration required | Wymagana ręczna konfiguracja
| | +##### Content + +| EN | PL | Notes | +| --------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- | +| Manage Sites | Zarządzanie witrynami | | +| Allow connectivity to your network through secure tunnels | Zezwalaj na łączność z Twoją siecią za pośrednictwem bezpiecznych tuneli | | +| Search sites | Szukaj witryny | placeholder | +| Add Site | Dodaj witrynę | | +| Name | Nazwa | table header | +| Online | Status | table header | +| Site | Witryna | table header | +| Data In | Dane wchodzące | table header | +| Data Out | Dane wychodzące | table header | +| Connection Type | Typ połączenia | table header | +| Online | Online | site state | +| Offline | Poza siecią | site state | +| Edit → | Edytuj → | | +| View settings | Pokaż ustawienia | Popup after clicking “…” on site | +| Delete | Usuń | Popup after clicking “…” on site | +##### Add Site Popup + +| EN | PL | Notes | +| ------------------------------------------------------ | --------------------------------------------------- | ----------- | +| Create Site | Utwórz witrynę | | +| Create a new site to start connection for this site | Utwórz nową witrynę aby rozpocząć połączenie | | +| Name | Nazwa | | +| Site name | Nazwa witryny | placeholder | +| This is the name that will be displayed for this site. | Tak będzie wyświetlana twoja witryna | desc | +| Method | Metoda | | +| Local | Lokalna | | +| Newt | Newt | | +| WireGuard | WireGuard | | +| This is how you will expose connections. | Tak będą eksponowane połączenie. | | +| You will only be able to see the configuration once. | Tą konfigurację możesz zobaczyć tylko raz. | | +| Learn how to install Newt on your system | Dowiedz się jak zainstalować Newt na twoim systemie | | +| I have copied the config | Skopiowałem konfigurację | | +| Create Site | Utwórz witrynę | | +| Close | Zamknij | | + +## Main “Resources” + +##### “Hero” section + +| EN | PL | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | +| Resources | Zasoby | | +| Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | | +| Secure connectivity with WireGuard encryption | Bezpieczna łączność z szyfrowaniem WireGuard | | +| Configure multiple authentication methods | Konfigurowanie wielu metod uwierzytelniania | | +| User and role-based access control | Kontrola dostępu oparta na użytkownikach i rolach | | +##### Content + +| EN | PL | Notes | +| -------------------------------------------------- | -------------------------------------------------------------- | -------------------- | +| Manage Resources | Zarządzaj zasobami | | +| Create secure proxies to your private applications | Twórz bezpieczne serwery proxy dla swoich prywatnych aplikacji | | +| Search resources | Szukaj w zasobach | placeholder | +| Name | Nazwa | | +| Site | Witryna | | +| Full URL | Pełny URL | | +| Authentication | Uwierzytelnianie | | +| Not Protected | Niezabezpieczony | authentication state | +| Protected | Zabezpieczony | authentication state | +| Edit → | Edytuj → | | +| Add Resource | Dodaj zasób | | +##### Add Resource Popup + +| EN | PL | Notes | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------- | +| Create Resource | Utwórz zasób | | +| Create a new resource to proxy request to your app | Utwórz nowy zasób, aby przekazywać żądania do swojej aplikacji | | +| Name | Nazwa | | +| My Resource | Nowy zasób | name placeholder | +| This is the name that will be displayed for this resource. | To jest nazwa, która będzie wyświetlana dla tego zasobu | | +| Subdomain | Subdomena | | +| Enter subdomain | Wprowadź subdomenę | | +| This is the fully qualified domain name that will be used to access the resource. | To jest pełna nazwa domeny, która będzie używana do dostępu do zasobu. | | +| Site | Witryna | | +| Search site… | Szukaj witryny… | Site selector popup | +| This is the site that will be used in the dashboard. | To jest witryna, która będzie używana w pulpicie nawigacyjnym. | | +| Create Resource | Utwórz zasób | | +| Close | Zamknij | | + + +## Main “User & Roles” +##### Content + +| EN | PL | Notes | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------- | +| Manage User & Roles | Zarządzanie użytkownikami i rolami | | +| Invite users and add them to roles to manage access to your organization | Zaproś użytkowników i przypisz im role, aby zarządzać dostępem do Twojej organizacji | | +| Users | Użytkownicy | sidebar item | +| Roles | Role | sidebar item | +| **User tab** | | | +| Search users | Wyszukaj użytkownika | placeholder | +| Invite User | Zaproś użytkownika | addbutton | +| Email | Email | table header | +| Status | Status | table header | +| Role | Rola | table header | +| Confirmed | Zatwierdzony | account status | +| Not confirmed (?) | Niezatwierdzony (?) | unknown for me account status | +| Owner | Właściciel | role | +| Admin | Administrator | role | +| Member | Użytkownik | role | +| **Roles Tab** | | | +| Search roles | Wyszukaj role | placeholder | +| Add Role | Dodaj role | addbutton | +| Name | Nazwa | table header | +| Description | Opis | table header | +| Admin | Administrator | role | +| Member | Użytkownik | role | +| Admin role with the most permissions | Rola administratora z najszerszymi uprawnieniami | admin role desc | +| Members can only view resources | Członkowie mogą jedynie przeglądać zasoby | member role desc | + +##### Invite User popup + +| EN | PL | Notes | +| ----------------- | ------------------------------------------ | ----------- | +| Invite User | Give new users access to your organization | | +| Email | Email | | +| Enter an email | Wprowadź email | placeholder | +| Role | Rola | | +| Select role | Wybierz role | placeholder | +| Vaild for | Ważne do | | +| 1 day | Dzień | | +| 2 days | 2 dni | | +| 3 days | 3 dni | | +| 4 days | 4 dni | | +| 5 days | 5 dni | | +| 6 days | 6 dni | | +| 7 days | 7 dni | | +| Create Invitation | Utwórz zaproszenie | | +| Close | Zamknij | | + + +## Main “Shareable Links” +##### “Hero” section + +| EN | PL | Notes | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | +| Shareable Links | Łącza do udostępniania | | +| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Twórz linki do udostępniania swoich zasobów. Linki zapewniają tymczasowy lub nieograniczony dostęp do zasobu. Możesz skonfigurować czas wygaśnięcia linku podczas jego tworzenia. | | +| Easy to create and share | Łatwe tworzenie i udostępnianie | | +| Configurable expiration duration | Konfigurowalny czas wygaśnięcia | | +| Secure and revocable | Bezpieczne i odwołalne | | +##### Content + +| EN | PL | Notes | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------- | +| Manage Shareable Links | Zarządzaj łączami do udostępniania | | +| Create shareable links to grant temporary or permament access to your resources | Utwórz łącze do udostępniania w celu przyznania tymczasowego lub stałego dostępu do zasobów | | +| Search links | Szukaj łączy | placeholder | +| Create Share Link | Utwórz nowe łącze | addbutton | +| Resource | Zasób | table header | +| Title | Tytuł | table header | +| Created | Utworzone | table header | +| Expires | Wygasa | table header | +| No links. Create one to get started. | Brak łączy. Utwórz, aby rozpocząć. | table placeholder | + +##### Create Shareable Link popup + +| EN | PL | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Create Shareable Link | Utwórz łącze do udostępnienia | | +| Anyone with this link can access the resource | Każdy kto ma ten link może korzystać z zasobu | | +| Resource | Zasób | | +| Select resource | Wybierz zasób | | +| Search resources… | Szukaj zasobów… | resource selector popup | +| Title (optional) | Tytuł (opcjonalny) | | +| Enter title | Wprowadź tytuł | placeholder | +| Expire in | Wygasa za | | +| Minutes | Minut | | +| Hours | Godzin | | +| Days | Dni | | +| Months | Miesięcy | | +| Years | Lat | | +| Never expire | Nie wygasa | | +| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Czas wygaśnięcia to okres, przez który link będzie aktywny i zapewni dostęp do zasobu. Po upływie tego czasu link przestanie działać, a użytkownicy, którzy go użyli, stracą dostęp do zasobu. | | +| Create Link | Utwórz łącze | | +| Close | Zamknij | | + + +## Main “General” + +| EN | PL | Notes | +| -------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------ | +| General | Ogólne | | +| Configure your organization’s general settings | Zarządzaj ogólnymi ustawieniami twoich organizacji | | +| General | Ogólne | sidebar item | +| Organization Settings | Ustawienia organizacji | | +| Manage your organization details and configuration | Zarządzaj szczegółami i konfiguracją organizacji | | +| Name | Nazwa | | +| This is the display name of the org | To jest wyświetlana nazwa Twojej organizacji | | +| Save Settings | Zapisz ustawienia | | +| Danger Zone | Niebezpieczna strefa | | +| Once you delete this org, there is no going back. Please be certain. | Jeśli usuniesz swoją tą organizację, nie ma odwrotu. Bądź ostrożny! | | +| Delete Organization Data | Usuń dane organizacji | | From a57f0ab3606a2dfd936273f168a573750ec835b9 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:23:19 -0500 Subject: [PATCH 24/56] log password reset token if no smtp to allow reset password --- server/routers/auth/requestPasswordReset.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 36cfed87..e4105f68 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -84,6 +84,10 @@ export async function requestPasswordReset( const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; + if (!config.getRawConfig().email) { + logger.info(`Password reset requested for ${email}. Token: ${token}.`); + } + await sendEmail( ResetPasswordCode({ email, From 60110350aa8b075eb1220a89f4e6cda08348803e Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:26:34 -0500 Subject: [PATCH 25/56] use smtp user if no no-reply set --- server/auth/resourceOtp.ts | 2 +- server/auth/sendEmailVerificationCode.ts | 2 +- server/lib/config.ts | 10 ++++++++-- server/routers/auth/requestPasswordReset.ts | 2 +- server/routers/auth/resetPassword.ts | 2 +- server/routers/user/inviteUser.ts | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/server/auth/resourceOtp.ts b/server/auth/resourceOtp.ts index 8c23e508..33675aa5 100644 --- a/server/auth/resourceOtp.ts +++ b/server/auth/resourceOtp.ts @@ -26,7 +26,7 @@ export async function sendResourceOtpEmail( }), { to: email, - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), subject: `Your one-time code to access ${resourceName}` } ); diff --git a/server/auth/sendEmailVerificationCode.ts b/server/auth/sendEmailVerificationCode.ts index 5fe2b280..54318760 100644 --- a/server/auth/sendEmailVerificationCode.ts +++ b/server/auth/sendEmailVerificationCode.ts @@ -21,7 +21,7 @@ export async function sendEmailVerificationCode( }), { to: email, - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), subject: "Verify your email address" } ); diff --git a/server/lib/config.ts b/server/lib/config.ts index 9476b505..1d91b9f6 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -41,7 +41,7 @@ const configSchema = z.object({ .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean(), - log_failed_attempts: z.boolean().optional(), + log_failed_attempts: z.boolean().optional() }), server: z.object({ external_port: portSchema @@ -128,7 +128,7 @@ const configSchema = z.object({ smtp_user: z.string().optional(), smtp_pass: z.string().optional(), smtp_secure: z.boolean().optional(), - no_reply: z.string().email() + no_reply: z.string().email().optional() }) .optional(), users: z.object({ @@ -280,6 +280,12 @@ export class Config { return this.rawConfig.app.base_domain; } + public getNoReplyEmail(): string | undefined { + return ( + this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user + ); + } + private createTraefikConfig() { try { // check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index e4105f68..08d563ad 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -95,7 +95,7 @@ export async function requestPasswordReset( link: url }), { - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), to: email, subject: "Reset your password" } diff --git a/server/routers/auth/resetPassword.ts b/server/routers/auth/resetPassword.ts index d112e98b..97b283c6 100644 --- a/server/routers/auth/resetPassword.ts +++ b/server/routers/auth/resetPassword.ts @@ -163,7 +163,7 @@ export async function resetPassword( }); await sendEmail(ConfirmPasswordReset({ email }), { - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), to: email, subject: "Password Reset Confirmation" }); diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 45240ed2..5bf7e17f 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -168,7 +168,7 @@ export async function inviteUser( }), { to: email, - from: config.getRawConfig().email?.no_reply, + from: config.getNoReplyEmail(), subject: "You're invited to join a Fossorial organization" } ); From 397036640ea1be517845ac53a38760c00daf2a00 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 21:39:17 -0500 Subject: [PATCH 26/56] add additional_middlewares --- server/lib/config.ts | 3 ++- server/routers/traefik/getTraefikConfig.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index 1d91b9f6..9ef4ca9b 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -90,7 +90,8 @@ const configSchema = z.object({ http_entrypoint: z.string(), https_entrypoint: z.string().optional(), cert_resolver: z.string().optional(), - prefer_wildcard_cert: z.boolean().optional() + prefer_wildcard_cert: z.boolean().optional(), + additional_middlewares: z.array(z.string()).optional() }), gerbil: z.object({ start_port: portSchema diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index bfc86405..d8832c61 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -106,13 +106,15 @@ export async function traefikConfigProvider( : {}), }; + 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], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], service: serviceName, rule: `Host(\`${fullDomain}\`)`, ...(resource.ssl ? { tls } : {}), From f874449d367a48b116660de9b5b2d821f508deed Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 28 Jan 2025 22:13:46 -0500 Subject: [PATCH 27/56] remove no reply check in send email --- server/emails/index.ts | 2 +- server/routers/resource/authWithWhitelist.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/emails/index.ts b/server/emails/index.ts index 156d491b..c1c2bc87 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -6,7 +6,7 @@ import logger from "@server/logger"; function createEmailClient() { const emailConfig = config.getRawConfig().email; - if (!emailConfig || !emailConfig.no_reply) { + if (!emailConfig) { logger.warn( "Email SMTP configuration is missing. Emails will not be sent." ); diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index dae36b24..147e2ba9 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -123,7 +123,7 @@ export async function authWithWhitelist( if (!whitelistedEmail) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( - `Email is not whitelisted. Resource ID: ${resource?.resourceId}. Email: ${email}. IP: ${req.ip}.` + `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } return next( From 0e04e82b880f29163953aa97b568fd7cedaf1fbd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 28 Jan 2025 22:26:45 -0500 Subject: [PATCH 28/56] Squashed commit of the following: commit c276d2193da5dbe7af5197bdf7e2bcce6f87b0cf Author: Owen Schwartz Date: Tue Jan 28 22:06:04 2025 -0500 Okay actually now commit 9afdc0aadc3f4fb4e811930bacff70a9e17eab9f Author: Owen Schwartz Date: Tue Jan 28 21:58:44 2025 -0500 Migrations working finally commit a7336b3b2466fe74d650b9c253ecadbe1eff749d Merge: e7c7203 fdb1ab4 Author: Owen Schwartz Date: Mon Jan 27 22:19:15 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit e7c7203330b1b08e570048b10ef314b55068e466 Author: Owen Schwartz Date: Mon Jan 27 22:18:09 2025 -0500 Working on migration commit a4704dfd44b10647257c7c7054c0dae806d315bb Author: Owen Schwartz Date: Mon Jan 27 21:40:52 2025 -0500 Add flag to allow raw resources commit d74f7a57ed11e2a6bf1a7e0c28c29fb07eb573a0 Merge: 6817788 d791b9b Author: Owen Schwartz 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 Date: Mon Jan 27 21:28:32 2025 -0500 Get everything working commit d791b9b47f9f6ca050d6edfd1d674438f8562d99 Author: Milo Schwartz Date: Mon Jan 27 17:46:19 2025 -0500 fix orgId check in verifyAdmin commit 6ac30afd7a449a126190d311bd98d7f1048f73a4 Author: Owen Schwartz Date: Sun Jan 26 23:19:33 2025 -0500 Trying to figure out traefik... commit 9886b42272882f8bb6baff2efdbe26cee7cac2b6 Merge: 786e67e 85e9129 Author: Owen Schwartz 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 Date: Sun Jan 26 21:51:37 2025 -0500 Bug fixing commit 85e9129ae313b2e4a460a8bc53a0af9f9fbbafb2 Author: Milo Schwartz Date: Sun Jan 26 18:35:24 2025 -0500 rethrow errors in migration and remove permanent redirect commit bd82699505fc7510c27f72cd80ea0ce815d8c5ef Author: Owen Schwartz Date: Sun Jan 26 17:49:12 2025 -0500 Fix merge issue commit 933dbf3a02b1f19fd1f627410b2407fdf05cd9bf Author: Owen Schwartz Date: Sun Jan 26 17:46:13 2025 -0500 Add sql to update resources and targets commit f19437bad847c8dbf57fddd2c48cd17bab20ddb0 Merge: 58980eb 9f1f291 Author: Owen Schwartz Date: Sun Jan 26 17:19:51 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit 58980ebb64d1040b4d224c76beb38c2254f3c5d9 Merge: 1de682a d284d36 Author: Owen Schwartz Date: Sun Jan 26 17:10:09 2025 -0500 Merge branch 'dev' into tcp-udp-traffic commit 1de682a9f6039f40e05c8901c7381a94b0d018ed Author: Owen Schwartz Date: Sun Jan 26 17:08:29 2025 -0500 Working on migrations commit dc853d2bc02b11997be5c3c7ea789402716fb4c2 Author: Owen Schwartz Date: Sun Jan 26 16:56:49 2025 -0500 Finish config of resource pages commit 37c681c08d7ab73d2cad41e7ef1dbe3a8852e1f2 Author: Owen Schwartz Date: Sun Jan 26 16:07:25 2025 -0500 Finish up table commit 461c6650bbea0d7439cc042971ec13fdb52a7431 Author: Owen Schwartz Date: Sun Jan 26 15:54:46 2025 -0500 Working toward having dual resource types commit f0894663627375e16ce6994370cb30b298efc2dc Author: Owen Schwartz Date: Sat Jan 25 22:31:25 2025 -0500 Add qutoes commit edc535b79b94c2e65b290cd90a69fe17d27245e9 Author: Owen Schwartz Date: Sat Jan 25 22:28:45 2025 -0500 Add readTimeout to allow long file uploads commit 194892fa14b505bd7c2b31873dc13d4b8996c0e1 Author: Owen Schwartz Date: Sat Jan 25 20:37:34 2025 -0500 Rework traefik config generation commit ad3f896b5333e4706d610c3198f29dcd67610365 Author: Owen Schwartz Date: Sat Jan 25 13:01:47 2025 -0500 Add proxy port to api commit ca6013b2ffda0924a696ec3141825a54a4e5297d Author: Owen Schwartz Date: Sat Jan 25 12:58:01 2025 -0500 Add migration commit 2258d76cb3a49d3db7f05f76d8b8a9f1c248b5e4 Author: Owen Schwartz Date: Sat Jan 25 12:55:02 2025 -0500 Add new proxy port --- config/config.example.yml | 1 + config/traefik/traefik_config.example.yml | 3 + install/fs/config.yml | 1 + install/fs/traefik/traefik_config.yml | 11 +- server/db/schema.ts | 10 +- server/lib/config.ts | 7 +- server/middlewares/verifyAdmin.ts | 2 +- server/routers/badger/exchangeSession.ts | 2 +- server/routers/badger/verifySession.ts | 2 +- server/routers/internal.ts | 12 +- server/routers/newt/handleRegisterMessage.ts | 94 +++-- server/routers/newt/targets.ts | 97 ++--- server/routers/resource/createResource.ts | 100 ++++- server/routers/resource/deleteResource.ts | 2 +- server/routers/resource/listResources.ts | 10 +- server/routers/resource/updateResource.ts | 2 +- server/routers/target/createTarget.ts | 11 +- server/routers/target/deleteTarget.ts | 6 +- server/routers/target/listTargets.ts | 1 - server/routers/target/updateTarget.ts | 8 +- server/routers/traefik/getTraefikConfig.ts | 342 +++++++++++------- server/routers/traefik/index.ts | 2 +- server/setup/scripts/1.0.0-beta9.ts | 118 +++++- .../settings/resources/CreateResourceForm.tsx | 241 +++++++++--- .../settings/resources/ResourcesTable.tsx | 51 ++- .../[resourceId]/ResourceInfoBox.tsx | 94 +++-- .../[resourceId]/connectivity/page.tsx | 67 ++-- .../resources/[resourceId]/general/page.tsx | 138 ++++--- .../resources/[resourceId]/layout.tsx | 11 +- src/app/[orgId]/settings/resources/page.tsx | 3 + src/lib/pullEnv.ts | 4 +- src/lib/types/env.ts | 1 + 32 files changed, 1003 insertions(+), 451 deletions(-) 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; } }; From 20f659db8955475ab278bc5c0aafe45c0a7da014 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 29 Jan 2025 00:03:10 -0500 Subject: [PATCH 29/56] fix zod schemas --- config/config.example.yml | 2 +- config/traefik/traefik_config.example.yml | 2 +- server/routers/resource/createResource.ts | 53 ++++---- server/routers/traefik/getTraefikConfig.ts | 4 +- .../settings/resources/CreateResourceForm.tsx | 39 +++--- .../[resourceId]/connectivity/page.tsx | 123 +++++++++--------- .../resources/[resourceId]/general/page.tsx | 57 +++++--- 7 files changed, 158 insertions(+), 122 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 788b5943..e62af16d 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -41,4 +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 + allow_raw_resources: true diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index 8a2263ad..01d05903 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -13,7 +13,7 @@ experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.0.0-beta.2" + version: "v1.0.0-beta.3" log: level: "INFO" diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index ee392e5f..e687cc02 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,6 +17,7 @@ import { eq, and } from "drizzle-orm"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; const createResourceParamsSchema = z .object({ @@ -27,36 +28,43 @@ 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(), + subdomain: z.string().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( + proxyPort: z.number().optional() + }) + .refine( (data) => { - if (data.http === true) { - return true; + if (!data.http) { + return z + .number() + .int() + .min(1) + .max(65535) + .safeParse(data.proxyPort).success; } - return !!data.proxyPort; + return true; }, { - message: "Port number is required for non-HTTP resources", + message: "Invalid port number", path: ["proxyPort"] } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"] + } ); - + export type CreateResourceResponse = Resource; export async function createResource( @@ -134,7 +142,6 @@ export async function createResource( ); } } else { - if (proxyPort === 443 || proxyPort === 80) { return next( createHttpError( @@ -149,7 +156,7 @@ export async function createResource( .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); - + if (existingResource.length > 0) { return next( createHttpError( @@ -165,7 +172,7 @@ export async function createResource( .insert(resources) .values({ siteId, - fullDomain: http? fullDomain : null, + fullDomain: http ? fullDomain : null, orgId, name, subdomain, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 4b760274..93eddde4 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -156,13 +156,15 @@ export async function traefikConfigProvider( : {}) }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + config_output.http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], - middlewares: [badgerMiddlewareName], + middlewares: [badgerMiddlewareName, ...additionalMiddlewares], service: serviceName, rule: `Host(\`${fullDomain}\`)`, ...(resource.ssl ? { tls } : {}) diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 7a111c06..d92a3792 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -59,38 +59,39 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { subdomainSchema } from "@server/schemas/subdomainSchema"; 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(), + subdomain: z.string().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() + proxyPort: z.number().optional(), }) .refine( (data) => { - if (data.http === true) { - return true; + if (!data.http) { + return z.number().int().min(1).max(65535).safeParse(data.proxyPort).success; } - return !!data.proxyPort; + return true; }, { - message: "Port number is required for non-HTTP resources", - path: ["proxyPort"] + message: "Invalid port number", + path: ["proxyPort"], + } + ) + .refine( + (data) => { + if (data.http) { + return subdomainSchema.safeParse(data.subdomain).success; + } + return true; + }, + { + message: "Invalid subdomain", + path: ["subdomain"], } ); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 2e38b193..c40102e3 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -63,6 +63,7 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { useSiteContext } from "@app/hooks/useSiteContext"; +import { InfoPopup } from "@app/components/ui/info-popup"; // Regular expressions for validation const DOMAIN_REGEX = @@ -458,28 +459,28 @@ export default function ReverseProxyTargets(props: { return ( {resource.http && ( - - - - SSL Configuration - - - Setup SSL to secure your connections with LetsEncrypt - certificates - - - - { - await saveSsl(val); - }} - /> - - -)} + + + + SSL Configuration + + + Setup SSL to secure your connections with + LetsEncrypt certificates + + + + { + await saveSsl(val); + }} + /> + + + )} {/* Targets Section */} @@ -498,41 +499,45 @@ export default function ReverseProxyTargets(props: { >
{resource.http && ( - - ( - - Method - - - - - - - - http - - - https - - - - - - - )} - /> - )} + ) => { + addTargetForm.setValue( + "method", + value + ); + }} + > + + + + + + http + + + https + + + + + + + )} + /> + )} - - Multiple targets will get load balanced by Traefik. You can use this for high availability. - +

+ Adding more than one target above will enable load balancing. +

From f5e894e06ad3d87cb6a1ed14a553a75bb41e94b6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 30 Jan 2025 21:10:24 -0500 Subject: [PATCH 47/56] Make sure secure_cookies is true --- package.json | 2 +- server/setup/migrations.ts | 6 ++-- server/setup/scripts/1.0.0-beta10.ts | 43 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta10.ts diff --git a/package.json b/package.json index 446e3d37..eceba242 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.9", + "version": "1.0.0-beta.10", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 7d1d7da4..2f1aae21 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -4,7 +4,7 @@ import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; import { desc } from "drizzle-orm"; -import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import { __DIRNAME } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; @@ -12,6 +12,7 @@ import m3 from "./scripts/1.0.0-beta3"; import m4 from "./scripts/1.0.0-beta5"; import m5 from "./scripts/1.0.0-beta6"; import m6 from "./scripts/1.0.0-beta9"; +import m7 from "./scripts/1.0.0-beta10"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -23,7 +24,8 @@ const migrations = [ { version: "1.0.0-beta.3", run: m3 }, { version: "1.0.0-beta.5", run: m4 }, { version: "1.0.0-beta.6", run: m5 }, - { version: "1.0.0-beta.9", run: m6 } + { version: "1.0.0-beta.9", run: m6 }, + { version: "1.0.0-beta.10", run: m7 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta10.ts b/server/setup/scripts/1.0.0-beta10.ts new file mode 100644 index 00000000..e5bf5ba2 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta10.ts @@ -0,0 +1,43 @@ +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import fs from "fs"; +import yaml from "js-yaml"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.9..."); + + try { + // Determine which config file exists + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + // Read and parse the YAML file + let rawConfig: any; + const fileContents = fs.readFileSync(filePath, "utf8"); + rawConfig = yaml.load(fileContents); + + rawConfig.server.secure_cookies = true; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + } catch (e) { + console.log( + `Failed to set secure_cookies to true in config. Please set it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + ); + return; + } + + console.log("Done."); +} From f40d91ff9ea40fe23bb5f7d0998f929816e3af6b Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 30 Jan 2025 21:53:42 -0500 Subject: [PATCH 48/56] remove secure_cookies option from config --- config/config.example.yml | 1 - install/fs/config.yml | 1 - server/auth/index.ts | 118 ----------------------- server/auth/sessions/app.ts | 14 +-- server/auth/sessions/resource.ts | 8 +- server/routers/badger/exchangeSession.ts | 3 +- server/routers/badger/verifySession.ts | 2 +- server/setup/scripts/1.0.0-beta10.ts | 6 +- 8 files changed, 12 insertions(+), 141 deletions(-) delete mode 100644 server/auth/index.ts diff --git a/config/config.example.yml b/config/config.example.yml index e62af16d..50c1a623 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -9,7 +9,6 @@ server: internal_port: 3001 next_port: 3002 internal_hostname: "pangolin" - secure_cookies: true session_cookie_name: "p_session_token" resource_access_token_param: "p_token" resource_session_request_param: "p_session_request" diff --git a/install/fs/config.yml b/install/fs/config.yml index d2a2ffec..4a97dbb5 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -9,7 +9,6 @@ server: internal_port: 3001 next_port: 3002 internal_hostname: "pangolin" - secure_cookies: true session_cookie_name: "p_session_token" resource_access_token_param: "p_token" resource_session_request_param: "p_session_request" diff --git a/server/auth/index.ts b/server/auth/index.ts deleted file mode 100644 index 4bf2c40d..00000000 --- a/server/auth/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - encodeBase32LowerCaseNoPadding, - encodeHexLowerCase, -} from "@oslojs/encoding"; -import { sha256 } from "@oslojs/crypto/sha2"; -import { Session, sessions, User, users } from "@server/db/schema"; -import db from "@server/db"; -import { eq } from "drizzle-orm"; -import config from "@server/lib/config"; -import type { RandomReader } from "@oslojs/crypto/random"; -import { generateRandomString } from "@oslojs/crypto/random"; - -export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; -export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; -export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; -export const COOKIE_DOMAIN = "." + config.getBaseDomain(); - -export function generateSessionToken(): string { - const bytes = new Uint8Array(20); - crypto.getRandomValues(bytes); - const token = encodeBase32LowerCaseNoPadding(bytes); - return token; -} - -export async function createSession( - token: string, - userId: string, -): Promise { - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), - ); - const session: Session = { - sessionId: sessionId, - userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), - }; - await db.insert(sessions).values(session); - return session; -} - -export async function validateSessionToken( - token: string, -): Promise { - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), - ); - const result = await db - .select({ user: users, session: sessions }) - .from(sessions) - .innerJoin(users, eq(sessions.userId, users.userId)) - .where(eq(sessions.sessionId, sessionId)); - if (result.length < 1) { - return { session: null, user: null }; - } - const { user, session } = result[0]; - if (Date.now() >= session.expiresAt) { - await db - .delete(sessions) - .where(eq(sessions.sessionId, session.sessionId)); - return { session: null, user: null }; - } - if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { - session.expiresAt = new Date( - Date.now() + SESSION_COOKIE_EXPIRES, - ).getTime(); - await db - .update(sessions) - .set({ - expiresAt: session.expiresAt, - }) - .where(eq(sessions.sessionId, session.sessionId)); - } - return { session, user }; -} - -export async function invalidateSession(sessionId: string): Promise { - await db.delete(sessions).where(eq(sessions.sessionId, sessionId)); -} - -export async function invalidateAllSessions(userId: string): Promise { - await db.delete(sessions).where(eq(sessions.userId, userId)); -} - -export function serializeSessionCookie(token: string): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; - } else { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; - } -} - -export function createBlankSessionTokenCookie(): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; - } else { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; - } -} - -const random: RandomReader = { - read(bytes: Uint8Array): void { - crypto.getRandomValues(bytes); - }, -}; - -export function generateId(length: number): string { - const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; - return generateRandomString(random, alphabet, length); -} - -export function generateIdFromEntropySize(size: number): string { - const buffer = crypto.getRandomValues(new Uint8Array(size)); - return encodeBase32LowerCaseNoPadding(buffer); -} - -export type SessionValidationResult = - | { session: Session; user: User } - | { session: null; user: null }; diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 29c54eee..e58ff815 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -24,7 +24,6 @@ export const SESSION_COOKIE_EXPIRES = 60 * 60 * config.getRawConfig().server.dashboard_session_length_hours; -export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const COOKIE_DOMAIN = "." + new URL(config.getRawConfig().app.dashboard_url).hostname; @@ -108,12 +107,7 @@ export function serializeSessionCookie( isSecure: boolean ): string { if (isSecure) { - logger.debug("Setting cookie for secure origin"); - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; - } else { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${COOKIE_DOMAIN}`; - } + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; } else { return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; } @@ -121,11 +115,7 @@ export function serializeSessionCookie( export function createBlankSessionTokenCookie(isSecure: boolean): string { if (isSecure) { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; - } else { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; - } + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; } else { return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`; } diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index e9dd9b96..0bc7f092 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -9,7 +9,6 @@ export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours; -export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export async function createResourceSession(opts: { token: string; @@ -170,7 +169,7 @@ export function serializeResourceSessionCookie( token: string, isHttp: boolean = false ): string { - if (SECURE_COOKIES && !isHttp) { + if (!isHttp) { return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`; } else { return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`; @@ -179,9 +178,10 @@ export function serializeResourceSessionCookie( export function createBlankResourceSessionTokenCookie( cookieName: string, - domain: string + domain: string, + isHttp: boolean = false ): string { - if (SECURE_COOKIES) { + if (!isHttp) { return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`; } else { return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`; diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 18925279..093dfbb9 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -12,8 +12,7 @@ import { serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; -import { generateSessionToken } from "@server/auth"; -import { SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; +import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import { response } from "@server/lib"; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b1a6fb12..5830d805 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -26,8 +26,8 @@ import { import { Resource, roleResources, userResources } from "@server/db/schema"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; -import { generateSessionToken } from "@server/auth"; import NodeCache from "node-cache"; +import { generateSessionToken } from "@server/auth/sessions/app"; // We'll see if this speeds anything up const cache = new NodeCache({ diff --git a/server/setup/scripts/1.0.0-beta10.ts b/server/setup/scripts/1.0.0-beta10.ts index e5bf5ba2..15c9532e 100644 --- a/server/setup/scripts/1.0.0-beta10.ts +++ b/server/setup/scripts/1.0.0-beta10.ts @@ -27,14 +27,16 @@ export default async function migration() { const fileContents = fs.readFileSync(filePath, "utf8"); rawConfig = yaml.load(fileContents); - rawConfig.server.secure_cookies = true; + delete rawConfig.server.secure_cookies; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Removed deprecated config option: secure_cookies.`); } catch (e) { console.log( - `Failed to set secure_cookies to true in config. Please set it manually. https://docs.fossorial.io/Pangolin/Configuration/config` + `Was unable to remove deprecated config option: secure_cookies. Error: ${e}` ); return; } From 844b12d3630073a8e5a77e033cea702cff540605 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 30 Jan 2025 22:31:29 -0500 Subject: [PATCH 49/56] add copy code snippets to raw tcp/udp --- server/lib/config.ts | 1 - .../settings/resources/CreateResourceForm.tsx | 549 ++++++++++-------- .../[resourceId]/connectivity/page.tsx | 2 +- 3 files changed, 320 insertions(+), 232 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index 66dd6764..14e96af1 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -60,7 +60,6 @@ const configSchema = z.object({ .transform(stoi) .pipe(portSchema), internal_hostname: z.string().transform((url) => url.toLowerCase()), - secure_cookies: z.boolean(), session_cookie_name: z.string(), resource_access_token_param: z.string(), resource_session_request_param: z.string(), diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 753d843d..11f215d4 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -62,6 +62,7 @@ import { import { subdomainSchema } from "@server/schemas/subdomainSchema"; import Link from "next/link"; import { SquareArrowOutUpRight } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; const createResourceFormSchema = z .object({ @@ -129,6 +130,10 @@ export default function CreateResourceForm({ const [sites, setSites] = useState([]); const [domainSuffix, setDomainSuffix] = useState(org.org.domain); + const [showSnippets, setShowSnippets] = useState(false); + + const [resourceId, setResourceId] = useState(null); + const form = useForm({ resolver: zodResolver(createResourceFormSchema), defaultValues: { @@ -186,11 +191,21 @@ export default function CreateResourceForm({ if (res && res.status === 201) { const id = res.data.data.resourceId; - // navigate to the resource page - router.push(`/${orgId}/settings/resources/${id}`); + setResourceId(id); + + if (data.http) { + goToResource(); + } else { + setShowSnippets(true); + } } } + function goToResource() { + // navigate to the resource page + router.push(`/${orgId}/settings/resources/${resourceId}`); + } + return ( <> -
- - ( - - Name - - - - - This is the name that will be - displayed for this resource. - - - - )} - /> - - {!env.flags.allowRawResources || ( + {!showSnippets && ( + + ( - -
- - HTTP Resource - - - Toggle if this is an - HTTP resource or a raw - TCP/UDP resource - -
- - - -
- )} - /> - )} - - {form.watch("http") && ( - ( - Subdomain + Name - - form.setValue( - "subdomain", - value - ) - } + - This is the fully qualified - domain name that will be - used to access the resource. + This is the name that will + be displayed for this + resource. )} /> - )} - {!form.watch("http") && ( - - - Learn how to configure TCP/UDP resources - - - - )} - - {!form.watch("http") && ( - <> + {!env.flags.allowRawResources || ( ( - - - Protocol - - - - The protocol to use for - the resource - - + +
+ + HTTP Resource + + + Toggle if this is an + HTTP resource or a + raw TCP/UDP resource + +
+ + +
)} /> + )} + + {form.watch("http") && ( ( - Port Number + Subdomain - - field.onChange( - e.target - .value - ? parseInt( - e - .target - .value - ) - : null + domainSuffix={ + domainSuffix + } + placeholder="Enter subdomain" + onChange={(value) => + form.setValue( + "subdomain", + value ) } /> - The port number to proxy - requests to (required - for non-HTTP resources) + This is the fully + qualified domain name + that will be used to + access the resource. )} /> - - )} - - ( - - Site - - - - - - - - - - - - No site found. - - - {sites.map( - (site) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - This is the site that will be - used in the dashboard. - - - )} - /> - - + + {!form.watch("http") && ( + + + Learn how to configure TCP/UDP + resources + + + + )} + + {!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) + + + + )} + /> + + )} + + ( + + Site + + + + + + + + + + + + No site + found. + + + {sites.map( + ( + site + ) => ( + { + form.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + This is the site that will + be used in the dashboard. + + + + )} + /> + + + )} + + {showSnippets && ( +
+
+
+ 1 +
+
+

+ Traefik: Add Entrypoints +

+ +
+
+ +
+
+ 2 +
+
+

+ Gerbil: Expose Ports in Docker + Compose +

+ +
+
+ + + + Make sure to follow the full guide + + + +
+ )}
- + } + + {showSnippets && } + diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index c40102e3..3c2b54c6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -130,7 +130,7 @@ export default function ReverseProxyTargets(props: { const addTargetForm = useForm({ resolver: zodResolver(addTargetSchema), defaultValues: { - ip: "localhost", + ip: "", method: resource.http ? "http" : null, port: resource.http ? 80 : resource.proxyPort || 1234 // protocol: "TCP", From 55c0953fdeb7d233bd78db5fbf8946a8ccf32e4d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Thu, 30 Jan 2025 22:43:47 -0500 Subject: [PATCH 50/56] update version in migration script log --- server/setup/scripts/1.0.0-beta10.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/setup/scripts/1.0.0-beta10.ts b/server/setup/scripts/1.0.0-beta10.ts index 15c9532e..6fd5289b 100644 --- a/server/setup/scripts/1.0.0-beta10.ts +++ b/server/setup/scripts/1.0.0-beta10.ts @@ -3,7 +3,7 @@ import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { - console.log("Running setup script 1.0.0-beta.9..."); + console.log("Running setup script 1.0.0-beta.10..."); try { // Determine which config file exists From 57cd776c340fd4899654059cb33a5c8c3d25557a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 30 Jan 2025 23:30:33 -0500 Subject: [PATCH 51/56] Fix migrations ordering --- server/setup/migrations.ts | 56 ++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 2f1aae21..b06f176c 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -3,9 +3,9 @@ import db, { exists } from "@server/db"; import path from "path"; import semver from "semver"; import { versionMigrations } from "@server/db/schema"; -import { desc } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; +import { SqliteError } from "better-sqlite3"; import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; @@ -53,38 +53,44 @@ export async function runMigrations() { } await db - .insert(versionMigrations) - .values({ - version: appVersion, - executedAt: Date.now() - }) - .execute(); + .insert(versionMigrations) + .values({ + version: appVersion, + executedAt: Date.now() + }) + .execute(); } } catch (e) { console.error("Error running migrations:", e); - await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)); + await new Promise((resolve) => + setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) + ); } } async function executeScripts() { try { // Get the last executed version from the database - const lastExecuted = await db - .select() - .from(versionMigrations) - .orderBy(desc(versionMigrations.version)) - .limit(1); - - const startVersion = lastExecuted[0]?.version ?? "0.0.0"; - console.log(`Starting migrations from version ${startVersion}`); + const lastExecuted = await db.select().from(versionMigrations); // Filter and sort migrations - const pendingMigrations = migrations - .filter((migration) => semver.gt(migration.version, startVersion)) - .sort((a, b) => semver.compare(a.version, b.version)); + const pendingMigrations = lastExecuted + .map((m) => m) + .sort((a, b) => semver.compare(b.version, a.version)); + const startVersion = pendingMigrations[0]?.version ?? "0.0.0"; + console.log(`Starting migrations from version ${startVersion}`); + + const migrationsToRun = migrations.filter((migration) => + semver.gt(migration.version, startVersion) + ); + + console.log( + "Migrations to run:", + migrationsToRun.map((m) => m.version).join(", ") + ); // Run migrations in order - for (const migration of pendingMigrations) { + for (const migration of migrationsToRun) { console.log(`Running migration ${migration.version}`); try { @@ -102,12 +108,16 @@ async function executeScripts() { console.log( `Successfully completed migration ${migration.version}` ); - } catch (error) { + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + console.error("Migration has already run! Skipping..."); + continue; + } console.error( `Failed to run migration ${migration.version}:`, - error + e ); - throw error; // Re-throw to stop migration process + throw e; // Re-throw to stop migration process } } From 81571a8fb72d0af371270ba7cca1ba5c7b8cd73b Mon Sep 17 00:00:00 2001 From: synologyy <137535302+synologyy@users.noreply.github.com> Date: Fri, 31 Jan 2025 09:01:00 +0100 Subject: [PATCH 52/56] german-translation --- internationalization/de.md | 267 +++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 internationalization/de.md diff --git a/internationalization/de.md b/internationalization/de.md new file mode 100644 index 00000000..1acd5b12 --- /dev/null +++ b/internationalization/de.md @@ -0,0 +1,267 @@ +## Login site + +| EN | DE | Notes | +| --------------------- | ---------------------------------- | ----------- | +| Welcome to Pangolin | Willkommen bei Pangolin | | +| Log in to get started | Melden Sie sich an, um zu beginnen | | +| Email | E-Mail | | +| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder | +| Password | Passwort | | +| Enter your password | Geben Sie Ihr Passwort ein | placeholder | +| Forgot your password? | Passwort vergessen? | | +| Log in | Anmelden | | + +# Ogranization site after successful login + +| EN | DE | Notes | +| ----------------------------------------- | -------------------------------------------- | ----- | +| Welcome to Pangolin | Willkommen bei Pangolin | | +| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | | + +## Shared Header, Navbar and Footer +##### Header + +| EN | DE | Notes | +| ------------------- | ------------------- | ----- | +| Documentation | Dokumentation | | +| Support | Support | | +| Organization {name} | Organisation {name} | | +##### Organization selector + +| EN | DE | Notes | +| ---------------- | ----------------- | ----- | +| Search… | Suchen… | | +| Create | Erstellen | | +| New Organization | Neue Organisation | | +| Organizations | Organisationen | | + +##### Navbar + +| EN | DE | Notes | +| --------------- | ----------------- | ----- | +| Sites | Websites | | +| Resources | Ressourcen | | +| User & Roles | Benutzer & Rollen | | +| Shareable Links | Teilbare Links | | +| General | Allgemein | | +##### Footer +| EN | DE | | +| ------------------------- | --------------------------- | ------------------- | +| Page {number} of {number} | Seite {number} von {number} | | +| Rows per page | Zeilen pro Seite | | +| Pangolin | Pangolin | unten auf der Seite | +| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite | +| Open Source | Open Source | unten auf der Seite | +| Documentation | Dokumentation | unten auf der Seite | +| {version} | {version} | unten auf der Seite | + +## Main “Sites” +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Newt (Recommended) | Newt (empfohlen) | | +| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | | +| Runs in Docker | Läuft in Docker | | +| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | | +| Install Newt | Newt installieren | | +| Basic WireGuard
| Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | | +| Compatible with all WireGuard clients
| Kompatibel mit allen WireGuard-Clients
| | +| Manual configuration required | Manuelle Konfiguration erforderlich
| | +##### Content + +| EN | DE | Notes | +| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- | +| Manage Sites | Seiten verwalten | | +| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | | +| Search sites | Seiten suchen | placeholder | +| Add Site | Seite hinzufügen | | +| Name | Name | table header | +| Online | Status | table header | +| Site | Seite | table header | +| Data In | Eingehende Daten | table header | +| Data Out | Ausgehende Daten | table header | +| Connection Type | Verbindungstyp | table header | +| Online | Online | site state | +| Offline | Offline | site state | +| Edit → | Bearbeiten → | | +| View settings | Einstellungen anzeigen | Popup after clicking “…” on site | +| Delete | Löschen | Popup after clicking “…” on site | +##### Add Site Popup + +| EN | DE | Notes | +| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- | +| Create Site | Seite erstellen | | +| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | | +| Name | Name | | +| Site name | Seiten-Name | placeholder | +| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc | +| Method | Methode | | +| Local | Lokal | | +| Newt | Newt | | +| WireGuard | WireGuard | | +| This is how you will expose connections. | So werden Verbindungen freigegeben. | | +| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | | +| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | | +| I have copied the config | Ich habe die Konfiguration kopiert | | +| Create Site | Website erstellen | | +| Close | Schließen | | + +## Main “Resources” + +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Resources | Ressourcen | | +| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | | +| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | | +| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | | +| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | | +##### Content + +| EN | DE | Notes | +| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- | +| Manage Resources | Ressourcen verwalten | | +| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | | +| Search resources | Ressourcen durchsuchen | placeholder | +| Name | Name | | +| Site | Website | | +| Full URL | Vollständige URL | | +| Authentication | Authentifizierung | | +| Not Protected | Nicht geschützt | authentication state | +| Protected | Geschützt | authentication state | +| Edit → | Bearbeiten → | | +| Add Resource | Ressource hinzufügen | | +##### Add Resource Popup + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- | +| Create Resource | Ressource erstellen | | +| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | | +| Name | Name | | +| My Resource | Neue Ressource | name placeholder | +| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | | +| Subdomain | Subdomain | | +| Enter subdomain | Subdomain eingeben | | +| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | | +| Site | Website | | +| Search site… | Website suchen… | Site selector popup | +| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | | +| Create Resource | Ressource erstellen | | +| Close | Schließen | | + + +## Main “User & Roles” +##### Content + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- | +| Manage User & Roles | Benutzer & Rollen verwalten | | +| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | | +| Users | Benutzer | sidebar item | +| Roles | Rollen | sidebar item | +| **User tab** | | | +| Search users | Benutzer suchen | placeholder | +| Invite User | Benutzer einladen | addbutton | +| Email | E-Mail | table header | +| Status | Status | table header | +| Role | Rolle | table header | +| Confirmed | Bestätigt | account status | +| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status | +| Owner | Besitzer | role | +| Admin | Administrator | role | +| Member | Mitglied | role | +| **Roles Tab** | | | +| Search roles | Rollen suchen | placeholder | +| Add Role | Rolle hinzufügen | addbutton | +| Name | Name | table header | +| Description | Beschreibung | table header | +| Admin | Administrator | role | +| Member | Mitglied | role | +| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc | +| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc | + +##### Invite User popup + +| EN | DE | Notes | +| ----------------- | ------------------------------------------------------- | ----------- | +| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | | +| Email | E-Mail | | +| Enter an email | E-Mail eingeben | placeholder | +| Role | Rolle | | +| Select role | Rolle auswählen | placeholder | +| Gültig für | Gültig bis | | +| 1 day | Tag | | +| 2 days | 2 Tage | | +| 3 days | 3 Tage | | +| 4 days | 4 Tage | | +| 5 days | 5 Tage | | +| 6 days | 6 Tage | | +| 7 days | 7 Tage | | +| Create Invitation | Einladung erstellen | | +| Close | Schließen | | + + +## Main “Shareable Links” +##### “Hero” section + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- | +| Shareable Links | Teilbare Links | | +| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | | +| Easy to create and share | Einfach zu erstellen und zu teilen | | +| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | | +| Secure and revocable | Sicher und widerrufbar | | +##### Content + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- | +| Manage Shareable Links | Teilbare Links verwalten | | +| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | | +| Search links | Links suchen | placeholder | +| Create Share Link | Neuen Link erstellen | addbutton | +| Resource | Ressource | table header | +| Title | Titel | table header | +| Created | Erstellt | table header | +| Expires | Gültig bis | table header | +| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder | + +##### Create Shareable Link popup + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- | +| Create Shareable Link | Teilbaren Link erstellen | | +| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | | +| Resource | Ressource | | +| Select resource | Ressource auswählen | | +| Search resources… | Ressourcen suchen… | resource selector popup | +| Title (optional) | Titel (optional) | | +| Enter title | Titel eingeben | placeholder | +| Expire in | Gültig bis | | +| Minutes | Minuten | | +| Hours | Stunden | | +| Days | Tage | | +| Months | Monate | | +| Years | Jahre | | +| Never expire | Nie ablaufen | | +| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | | +| Create Link | Link erstellen | | +| Close | Schließen | | + + +## Main “General” + +| EN | DE | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ | +| General | Allgemein | | +| Configure your organization’s general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | | +| General | Allgemein | sidebar item | +| Organization Settings | Organisationseinstellungen | | +| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | | +| Name | Name | | +| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | | +| Save Settings | Einstellungen speichern | | +| Danger Zone | Gefahrenzone | | +| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | | +| Delete Organization Data | Organisationsdaten löschen | | \ No newline at end of file From bb5573a8f485eab92967d0920eec16aa5b9afd2a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Fri, 31 Jan 2025 15:03:36 -0500 Subject: [PATCH 53/56] allow comma in password closes #121 --- server/auth/passwordSchema.ts | 2 +- src/app/[orgId]/settings/resources/CreateResourceForm.tsx | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/server/auth/passwordSchema.ts b/server/auth/passwordSchema.ts index f4030dee..5554b741 100644 --- a/server/auth/passwordSchema.ts +++ b/server/auth/passwordSchema.ts @@ -4,7 +4,7 @@ export const passwordSchema = z .string() .min(8, { message: "Password must be at least 8 characters long" }) .max(64, { message: "Password must be at most 64 characters long" }) - .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, { + .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[,#?!@$%^&*-]).*$/, { message: `Your password must meet the following conditions: at least one uppercase English letter, at least one lowercase English letter, diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx index 11f215d4..abe5608c 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/CreateResourceForm.tsx @@ -164,8 +164,6 @@ export default function CreateResourceForm({ }, [open]); async function onSubmit(data: CreateResourceFormValues) { - console.log(data); - const res = await api .put>( `/org/${orgId}/site/${data.siteId}/resource/`, @@ -194,16 +192,16 @@ export default function CreateResourceForm({ setResourceId(id); if (data.http) { - goToResource(); + goToResource(id); } else { setShowSnippets(true); } } } - function goToResource() { + function goToResource(id?: number) { // navigate to the resource page - router.push(`/${orgId}/settings/resources/${resourceId}`); + router.push(`/${orgId}/settings/resources/${id || resourceId}`); } return ( From a9477d7eb93e8d87e1dd0dd5c9026de06e8f90eb Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Fri, 31 Jan 2025 15:06:25 -0500 Subject: [PATCH 54/56] Complex filter generating config; Resolves #124 --- server/routers/traefik/getTraefikConfig.ts | 77 ++++++++++++++-------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 93eddde4..7c12cdb3 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -118,14 +118,6 @@ export async function traefikConfigProvider( 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 = {}; @@ -156,7 +148,8 @@ export async function traefikConfigProvider( : {}) }; - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; config_output.http.routers![routerName] = { entryPoints: [ @@ -164,7 +157,10 @@ export async function traefikConfigProvider( ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], - middlewares: [badgerMiddlewareName, ...additionalMiddlewares], + middlewares: [ + badgerMiddlewareName, + ...additionalMiddlewares + ], service: serviceName, rule: `Host(\`${fullDomain}\`)`, ...(resource.ssl ? { tls } : {}) @@ -184,9 +180,31 @@ export async function traefikConfigProvider( config_output.http.services![serviceName] = { loadBalancer: { servers: targets - .filter( - (target: Target) => target.internalPort != null - ) + .filter((target: Target) => { + if (!target.enabled) { + return false; + } + if ( + site.type === "local" || + site.type === "wireguard" + ) { + if ( + !target.ip || + !target.port || + !target.method + ) { + return false; + } + } else if (site.type === "newt") { + if ( + !target.internalPort || + !target.method + ) { + return false; + } + } + return true; + }) .map((target: Target) => { if ( site.type === "local" || @@ -213,14 +231,6 @@ export async function traefikConfigProvider( continue; } - if ( - targets.filter( - (target: Target) => target.internalPort != null - ).length == 0 - ) { - continue; - } - if (!config_output[protocol]) { config_output[protocol] = { routers: {}, @@ -237,9 +247,24 @@ export async function traefikConfigProvider( config_output[protocol].services[serviceName] = { loadBalancer: { servers: targets - .filter( - (target: Target) => target.internalPort != null - ) + .filter((target: Target) => { + if (!target.enabled) { + return false; + } + if ( + site.type === "local" || + site.type === "wireguard" + ) { + if (!target.ip || !target.port) { + return false; + } + } else if (site.type === "newt") { + if (!target.internalPort) { + return false; + } + } + return true; + }) .map((target: Target) => { if ( site.type === "local" || @@ -261,9 +286,9 @@ export async function traefikConfigProvider( } return res.status(HttpCode.OK).json(config_output); } catch (e) { - logger.error(`Failed to build traefik config: ${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" }); } } From 8b43c6f9c58113700cf9284fe89bfc2cdb1fc899 Mon Sep 17 00:00:00 2001 From: Kamil <32385799+nkkfs@users.noreply.github.com> Date: Sat, 1 Feb 2025 08:56:20 +0100 Subject: [PATCH 55/56] Update pl.md Add Authentication Site strings --- internationalization/pl.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internationalization/pl.md b/internationalization/pl.md index 4414a908..a55866e2 100644 --- a/internationalization/pl.md +++ b/internationalization/pl.md @@ -1,3 +1,23 @@ +## Authentication Site + + +| EN | PL | Notes | +| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- | +| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | | +| Authentication Required | Wymagane uwierzytelnienie | | +| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | | +| PIN | PIN | | +| User | Zaloguj | | +| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login | +| Login in with PIN | Zaloguj się PIN’em | pin login | +| Email | Email | user login | +| Enter your email | Wprowadź swój email | user login | +| Password | Hasło | user login | +| Enter your password | Wprowadź swoje hasło | user login | +| Forgot your password? | Zapomniałeś hasła? | user login | +| Log in | Zaloguj | user login | + + ## Login site | EN | PL | Notes | From dc9b1f1efd4c9a0554cddd9b5fbddc040933499a Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sat, 1 Feb 2025 16:52:18 -0500 Subject: [PATCH 56/56] add project board to readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6911213f..2c887483 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) [![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) -Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. +Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI. ### Installation and Documentation @@ -129,6 +129,10 @@ Pangolin was inspired by several existing projects and concepts: - **Authentik and Authelia**: These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. +## Project Development / Roadmap + +Pangolin is under active development, and we are continuously adding new features and improvements. View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info. + ## Licensing Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.