From 7ab89b1adb01006b0d303682a5419facae030490 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 6 Jan 2025 22:25:37 -0500 Subject: [PATCH 01/19] add example config dir, logos, and update CONTRIBUTING.md --- .gitignore | 2 +- CONTRIBUTING.md | 10 ++++++-- Dockerfile | 2 +- config/.gitkeep | 0 .../config.example.yml | 23 +++++------------- config/db/.gitkeep | 0 config/logs/.gitkeep | 0 public/logo/pangolin_orange_192x192.png | Bin 0 -> 8028 bytes public/logo/pangolin_orange_512x512.png | Bin 0 -> 27814 bytes public/logo/pangolin_orange_96x96.png | Bin 0 -> 3749 bytes 10 files changed, 16 insertions(+), 21 deletions(-) create mode 100644 config/.gitkeep rename config.example.yml => config/config.example.yml (55%) create mode 100644 config/db/.gitkeep create mode 100644 config/logs/.gitkeep create mode 100644 public/logo/pangolin_orange_192x192.png create mode 100644 public/logo/pangolin_orange_512x512.png create mode 100644 public/logo/pangolin_orange_96x96.png diff --git a/.gitignore b/.gitignore index d5a84925..9c4578e9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ next-env.d.ts migrations package-lock.json tsconfig.tsbuildinfo -config/ +config/config.yml dist .dist installer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e32f6255..44acedb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,12 @@ ## Contributing -Contributions are welcome! Please see the following page in our documentation with future plans and feature ideas if you are looking for a place to start. +Contributions are welcome! + +Please see the contribution and local development guide on the docs page before getting started: + +https://docs.fossorial.io/development + +For ideas about what features to work on and our future plans, please see the roadmap: https://docs.fossorial.io/roadmap @@ -15,4 +21,4 @@ By creating this pull request, I grant the project maintainers an unlimited, perpetual license to use, modify, and redistribute these contributions under any terms they choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. -``` \ No newline at end of file +``` diff --git a/Dockerfile b/Dockerfile index 2f49d091..aeeafeb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ COPY --from=builder /app/.next ./.next COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init -COPY config.example.yml ./dist/config.example.yml +COPY config/config.example.yml ./dist/config.example.yml COPY server/db/names.json ./dist/names.json COPY public ./public diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/config.example.yml b/config/config.example.yml similarity index 55% rename from config.example.yml rename to config/config.example.yml index b5a109f4..0b5d1714 100644 --- a/config.example.yml +++ b/config/config.example.yml @@ -1,13 +1,13 @@ app: - base_url: https://proxy.example.com - log_level: info + base_url: http://localhost + log_level: debug save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: pangolin + internal_hostname: localhost secure_cookies: false session_cookie_name: p_session resource_session_cookie_name: p_resource_session @@ -16,34 +16,23 @@ traefik: cert_resolver: letsencrypt http_entrypoint: web https_entrypoint: websecure - prefer_wildcard_cert: true gerbil: start_port: 51820 - base_endpoint: proxy.example.com - use_subdomain: false + base_endpoint: localhost block_size: 16 subnet_group: 10.0.0.0/8 + use_subdomain: true rate_limits: global: window_minutes: 1 max_requests: 100 -email: - smtp_host: host.hoster.net - smtp_port: 587 - smtp_user: no-reply@example.com - smtp_pass: aaaaaaaaaaaaaaaaaa - no_reply: no-reply@example.com - users: server_admin: email: admin@example.com password: Password123! flags: - require_email_verification: true - disable_signup_without_invite: true - disable_user_create_org: true - + require_email_verification: false diff --git a/config/db/.gitkeep b/config/db/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/config/logs/.gitkeep b/config/logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/logo/pangolin_orange_192x192.png b/public/logo/pangolin_orange_192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..52e8659b97aa3c25beb0e68cd06a57250ac65dd8 GIT binary patch literal 8028 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 literal 0 HcmV?d00001 diff --git a/public/logo/pangolin_orange_512x512.png b/public/logo/pangolin_orange_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..21f27644b6cb08db91f3b98320460da2b8db6341 GIT binary patch literal 27814 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 P00000NkvXXu0mjfexd(V literal 0 HcmV?d00001 From 26a165ab7175bbc0fe9163fa9844a81bc07d9868 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 6 Jan 2025 22:36:06 -0500 Subject: [PATCH 02/19] update dockerignore --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index b19988b9..a8b39fa6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,7 +23,6 @@ next-env.d.ts .machinelogs*.json *-audit.json package-lock.json -config/ install/ bruno/ LICENSE From e1f0834af4b483639100449c0dbe94738090a3b4 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 7 Jan 2025 20:32:24 -0500 Subject: [PATCH 03/19] split base_url into dashboard_url and base_domain --- Makefile | 14 ++--- README.md | 5 +- config/config.example.yml | 3 +- install/fs/config.yml | 3 +- package.json | 2 +- server/apiServer.ts | 2 +- server/auth/sendEmailVerificationCode.ts | 2 +- server/lib/config.ts | 26 ++++----- server/lib/consts.ts | 3 ++ server/routers/auth/requestPasswordReset.ts | 2 +- server/routers/badger/verifySession.ts | 2 +- server/routers/org/createOrg.ts | 1 - server/routers/user/inviteUser.ts | 2 +- server/setup/copyInConfig.ts | 1 - server/setup/migrations.ts | 4 +- server/setup/scripts/1.0.0-beta1.ts | 6 +-- server/setup/scripts/1.0.0-beta2.ts | 59 +++++++++++++++++++++ 17 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 server/setup/scripts/1.0.0-beta2.ts diff --git a/Makefile b/Makefile index 4b54dd55..de182bfe 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,20 @@ - -all: build push +build-all: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-all tag="; \ + exit 1; \ + fi + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:latest -f Dockerfile --push . + docker buildx build --platform linux/arm64,linux/amd64 -t fosrl/pangolin:$(tag) -f Dockerfile --push . build-arm: docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . build-x86: - docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . + docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . build: docker build -t fosrl/pangolin:latest . -push: - docker push fosrl/pangolin:latest - test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest diff --git a/README.md b/README.md index 01ae93c7..759f083b 100644 --- a/README.md +++ b/README.md @@ -123,4 +123,7 @@ Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. ## Contributions -Please see [CONTRIBUTIONS](./CONTRIBUTING.md) in the repository for guidelines and best practices. +Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices. + +Please post bug reports and other functional issues in the [Issues](https://github.com/fosrl/pangolin/issues) section of the repository. +For all feature requests, or other ideas, please use the [Discussions](https://github.com/orgs/fosrl/discussions) section. diff --git a/config/config.example.yml b/config/config.example.yml index 0b5d1714..9311514e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,5 +1,6 @@ app: - base_url: http://localhost + dashboard_url: http://localhost + base_domain: localhost log_level: debug save_logs: false diff --git a/install/fs/config.yml b/install/fs/config.yml index 17c8b5ef..2ad323f0 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -1,5 +1,6 @@ app: - base_url: https://{{.Domain}} + dashboard_url: https://{{.Domain}} + base_domain: {{.Domain}} log_level: info save_logs: false diff --git a/package.json b/package.json index 9496afe0..e05785bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.1", + "version": "1.0.0-beta.2", "private": true, "type": "module", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", diff --git a/server/apiServer.ts b/server/apiServer.ts index 9a1a98d4..27796be9 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -31,7 +31,7 @@ export function createApiServer() { ); } else { const corsOptions = { - origin: config.getRawConfig().app.base_url, + origin: config.getRawConfig().app.dashboard_url, methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], allowedHeaders: ["Content-Type", "X-CSRF-Token"] }; diff --git a/server/auth/sendEmailVerificationCode.ts b/server/auth/sendEmailVerificationCode.ts index 57523a50..5fe2b280 100644 --- a/server/auth/sendEmailVerificationCode.ts +++ b/server/auth/sendEmailVerificationCode.ts @@ -17,7 +17,7 @@ export async function sendEmailVerificationCode( VerifyEmail({ username: email, verificationCode: code, - verifyLink: `${config.getRawConfig().app.base_url}/auth/verify-email` + verifyLink: `${config.getRawConfig().app.dashboard_url}/auth/verify-email` }), { to: email, diff --git a/server/lib/config.ts b/server/lib/config.ts index 8fdc455d..35540ba7 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -3,18 +3,25 @@ import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; const portSchema = z.number().positive().gt(0).lte(65535); +const hostnameSchema = z + .string() + .regex( + /^(?!-)[a-zA-Z0-9-]{1,63}(? url.toLowerCase()), + base_domain: hostnameSchema, log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean() }), @@ -58,7 +65,7 @@ const environmentSchema = z.object({ smtp_port: portSchema, smtp_user: z.string(), smtp_pass: z.string(), - no_reply: z.string().email(), + no_reply: z.string().email() }) .optional(), users: z.object({ @@ -99,9 +106,6 @@ export class Config { } }; - const configFilePath1 = path.join(APP_PATH, "config.yml"); - const configFilePath2 = path.join(APP_PATH, "config.yaml"); - let environment: any; if (fs.existsSync(configFilePath1)) { environment = loadConfig(configFilePath1); @@ -190,15 +194,7 @@ export class Config { } public getBaseDomain(): string { - const newUrl = new URL(this.rawConfig.app.base_url); - const hostname = newUrl.hostname; - const parts = hostname.split("."); - - if (parts.length <= 2) { - return parts.join("."); - } - - return parts.slice(1).join("."); + return this.rawConfig.app.base_domain; } } diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 156b334d..a444f9c5 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -6,3 +6,6 @@ export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); export const APP_PATH = path.join("config"); + +export const configFilePath1 = path.join(APP_PATH, "config.yml"); +export const configFilePath2 = path.join(APP_PATH, "config.yaml"); diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index e3d1de3e..a223e5f2 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -82,7 +82,7 @@ export async function requestPasswordReset( }); }); - const url = `${config.getRawConfig().app.base_url}/auth/reset-password?email=${email}&token=${token}`; + const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; await sendEmail( ResetPasswordCode({ diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 756fd040..459219c5 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -101,7 +101,7 @@ export async function verifyResourceSession( return allowed(res); } - const redirectUrl = `${config.getRawConfig().app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; if (!sessions) { return notAllowed(res); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index b86dfd18..3c25c0c3 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -82,7 +82,6 @@ export async function createOrg( let org: Org | null = null; await db.transaction(async (trx) => { - // create a url from config.getRawConfig().app.base_url and get the hostname const domain = config.getBaseDomain(); const newOrg = await trx diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 7b771499..3031e399 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -152,7 +152,7 @@ export async function inviteUser( }); }); - const inviteLink = `${config.getRawConfig().app.base_url}/invite?token=${inviteId}-${token}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`; if (doEmail) { await sendEmail( diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index c3ca1613..0ff3ba7f 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -5,7 +5,6 @@ import { eq, ne } from "drizzle-orm"; import logger from "@server/logger"; export async function copyInConfig() { - // create a url from config.getRawConfig().app.base_url and get the hostname const domain = config.getBaseDomain(); const endpoint = config.getRawConfig().gerbil.base_endpoint; diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 5483b2a6..7b1ad8ce 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -7,13 +7,15 @@ import { desc } from "drizzle-orm"; 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA // Define the migration list with versions and their corresponding functions const migrations = [ - { version: "1.0.0-beta.1", run: m1 } + { version: "1.0.0-beta.1", run: m1 }, + { version: "1.0.0-beta.2", run: m2 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta1.ts b/server/setup/scripts/1.0.0-beta1.ts index 1a564836..65d9ad1b 100644 --- a/server/setup/scripts/1.0.0-beta1.ts +++ b/server/setup/scripts/1.0.0-beta1.ts @@ -1,7 +1,5 @@ -import logger from "@server/logger"; - export default async function migration() { - console.log("Running setup script 1.0.0-beta.1"); + console.log("Running setup script 1.0.0-beta.1..."); // SQL operations would go here in ts format - console.log("Done..."); + console.log("Done."); } diff --git a/server/setup/scripts/1.0.0-beta2.ts b/server/setup/scripts/1.0.0-beta2.ts new file mode 100644 index 00000000..f8aa9bc3 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta2.ts @@ -0,0 +1,59 @@ +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.2..."); + + // 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); + + // Validate the structure + if (!rawConfig.app || !rawConfig.app.base_url) { + throw new Error(`Invalid config file: app.base_url is missing.`); + } + + // Move base_url to dashboard_url and calculate base_domain + const baseUrl = rawConfig.app.base_url; + rawConfig.app.dashboard_url = baseUrl; + rawConfig.app.base_domain = getBaseDomain(baseUrl); + + // Remove the old base_url + delete rawConfig.app.base_url; + + // Write the updated YAML back to the file + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log("Done."); +} + +function getBaseDomain(url: string): string { + const newUrl = new URL(url); + const hostname = newUrl.hostname; + const parts = hostname.split("."); + + if (parts.length <= 2) { + return parts.join("."); + } + + return parts.slice(-2).join("."); +} From b4dd827ce160e8ac5cc3daedaea8dd012256dffd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 7 Jan 2025 21:25:49 -0500 Subject: [PATCH 04/19] Remove unessicary ports --- docker-compose.example.yml | 3 --- install/fs/docker-compose.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index b736e94d..13b59b29 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -5,9 +5,6 @@ services: image: fosrl/pangolin:1.0.0-beta.1 container_name: pangolin restart: unless-stopped - ports: - - 3001:3001 - - 3000:3000 volumes: - ./config:/app/config healthcheck: diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index bf08aaf1..4d74807e 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -3,9 +3,6 @@ services: image: fosrl/pangolin:1.0.0-beta.1 container_name: pangolin restart: unless-stopped - ports: - - 3001:3001 - - 3000:3000 volumes: - ./config:/app/config healthcheck: From ab69ded3967f2c5008d8fc123c82e18ee251b1f7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 7 Jan 2025 21:31:32 -0500 Subject: [PATCH 05/19] Allow anything for the ip --- server/routers/target/createTarget.ts | 2 +- server/routers/target/updateTarget.ts | 2 +- .../settings/resources/[resourceId]/connectivity/page.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 742cdf68..de9ac1f3 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -23,7 +23,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: z.string().ip().or(z.literal('localhost')), + ip: z.string(), method: z.string().min(1).max(10), port: z.number().int().min(1).max(65535), protocol: z.string().optional(), diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 3e288020..8d774bac 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -19,7 +19,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: z.string().ip().or(z.literal('localhost')).optional(), // for now we cant update the ip; you will have to delete + ip: z.string().optional(), method: z.string().min(1).max(10).optional(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 9b858835..cb2ff5ac 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -64,7 +64,7 @@ import { import { SwitchInput } from "@app/components/SwitchInput"; const addTargetSchema = z.object({ - ip: z.union([z.string().ip(), z.literal("localhost")]), + ip: z.string(), method: z.string(), port: z.coerce.number().int().positive() // protocol: z.string(), @@ -179,7 +179,7 @@ export default function ReverseProxyTargets(props: { // make sure that the target IP is within the site subnet const targetIp = data.ip; const subnet = site.subnet; - if (targetIp === "localhost" || !isIPInSubnet(targetIp, subnet)) { + if (!isIPInSubnet(targetIp, subnet)) { toast({ variant: "destructive", title: "Invalid target IP", From fb754bc4e0d5ada7c28784dc8516e43f19177dfd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 7 Jan 2025 21:45:12 -0500 Subject: [PATCH 06/19] Update docker tags --- docker-compose.example.yml | 4 ++-- install/fs/docker-compose.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 13b59b29..b6184c67 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -2,7 +2,7 @@ version: "3.7" services: pangolin: - image: fosrl/pangolin:1.0.0-beta.1 + image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped volumes: @@ -14,7 +14,7 @@ services: retries: 5 gerbil: - image: fosrl/gerbil:1.0.0-beta.1 + image: fosrl/gerbil:latest container_name: gerbil restart: unless-stopped depends_on: diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index 4d74807e..47fd82f8 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -1,6 +1,6 @@ services: pangolin: - image: fosrl/pangolin:1.0.0-beta.1 + image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped volumes: @@ -12,7 +12,7 @@ services: retries: 5 gerbil: - image: fosrl/gerbil:1.0.0-beta.1 + image: fosrl/gerbil:latest container_name: gerbil restart: unless-stopped depends_on: From dc7bd41eb90aefc7512dda48a81a5c71f98e5690 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Tue, 7 Jan 2025 21:52:45 -0500 Subject: [PATCH 07/19] Complex regex for domains/ips --- server/routers/target/createTarget.ts | 30 ++++++++++++++++++- server/routers/target/updateTarget.ts | 30 ++++++++++++++++++- .../[resourceId]/connectivity/page.tsx | 30 ++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index de9ac1f3..e7ae3aca 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -12,6 +12,34 @@ import { isIpInCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; +// Regular expressions for validation +const DOMAIN_REGEX = + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + +// Schema for domain names and IP addresses +const domainSchema = z + .string() + .min(1, "Domain cannot be empty") + .max(255, "Domain name too long") + .refine( + (value) => { + // Check if it's a valid IP address (v4 or v6) + if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { + return true; + } + + // Check if it's a valid domain name + return DOMAIN_REGEX.test(value); + }, + { + message: "Invalid domain name or IP address format", + path: ["domain"] + } + ); + const createTargetParamsSchema = z .object({ resourceId: z @@ -23,7 +51,7 @@ const createTargetParamsSchema = z const createTargetSchema = z .object({ - ip: z.string(), + ip: domainSchema, method: z.string().min(1).max(10), port: z.number().int().min(1).max(65535), protocol: z.string().optional(), diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 8d774bac..77f127e8 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -11,6 +11,34 @@ import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +// Regular expressions for validation +const DOMAIN_REGEX = + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + +// Schema for domain names and IP addresses +const domainSchema = z + .string() + .min(1, "Domain cannot be empty") + .max(255, "Domain name too long") + .refine( + (value) => { + // Check if it's a valid IP address (v4 or v6) + if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { + return true; + } + + // Check if it's a valid domain name + return DOMAIN_REGEX.test(value); + }, + { + message: "Invalid domain name or IP address format", + path: ["domain"] + } + ); + const updateTargetParamsSchema = z .object({ targetId: z.string().transform(Number).pipe(z.number().int().positive()) @@ -19,7 +47,7 @@ const updateTargetParamsSchema = z const updateTargetBodySchema = z .object({ - ip: z.string().optional(), + ip: domainSchema.optional(), method: z.string().min(1).max(10).optional(), port: z.number().int().min(1).max(65535).optional(), enabled: z.boolean().optional() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index cb2ff5ac..a6d8821b 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -63,8 +63,36 @@ import { } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; +// Regular expressions for validation +const DOMAIN_REGEX = + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; + +// Schema for domain names and IP addresses +const domainSchema = z + .string() + .min(1, "Domain cannot be empty") + .max(255, "Domain name too long") + .refine( + (value) => { + // Check if it's a valid IP address (v4 or v6) + if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) { + return true; + } + + // Check if it's a valid domain name + return DOMAIN_REGEX.test(value); + }, + { + message: "Invalid domain name or IP address format", + path: ["domain"] + } + ); + const addTargetSchema = z.object({ - ip: z.string(), + ip: domainSchema, method: z.string(), port: z.coerce.number().int().positive() // protocol: z.string(), From b598fc3fbaecbe3f034aab40db23127e5a5ef26d Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Tue, 7 Jan 2025 22:37:20 -0500 Subject: [PATCH 08/19] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c4578e9..a4ca8de1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ config/config.yml dist .dist installer +*.tar From a556339b7667e7aa43234c4ee884ad8021c3a6b6 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 8 Jan 2025 23:13:35 -0500 Subject: [PATCH 09/19] allow hyphens in base_domain regex --- server/lib/config.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index 35540ba7..6642e7d3 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -3,7 +3,11 @@ import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { + __DIRNAME, + configFilePath1, + configFilePath2 +} from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; @@ -11,9 +15,9 @@ const portSchema = z.number().positive().gt(0).lte(65535); const hostnameSchema = z .string() .regex( - /^(?!-)[a-zA-Z0-9-]{1,63}(? Date: Thu, 9 Jan 2025 23:21:57 -0500 Subject: [PATCH 10/19] verify redirects are safe before redirecting --- server/routers/badger/verifySession.ts | 3 ++- src/app/[orgId]/layout.tsx | 2 +- src/app/[orgId]/settings/general/layout.tsx | 2 +- src/app/[orgId]/settings/layout.tsx | 2 +- src/app/auth/login/DashboardLoginForm.tsx | 8 ++++---- src/app/auth/login/page.tsx | 12 +++++++++--- .../auth/reset-password/ResetPasswordForm.tsx | 7 +++---- src/app/auth/reset-password/page.tsx | 8 +++++++- .../[resourceId]/ResourceAuthPortal.tsx | 6 +----- src/app/auth/resource/[resourceId]/page.tsx | 12 +++++++++++- src/app/auth/signup/SignupForm.tsx | 11 ++++++----- src/app/auth/signup/page.tsx | 12 +++++++++--- src/app/auth/verify-email/VerifyEmailForm.tsx | 7 +++---- src/app/auth/verify-email/page.tsx | 8 +++++++- src/app/layout.tsx | 17 ++++++++++------- src/app/page.tsx | 7 +++++-- src/components/LoginForm.tsx | 2 +- src/lib/cleanRedirect.ts | 18 ++++++++++++++++++ 18 files changed, 99 insertions(+), 45 deletions(-) create mode 100644 src/lib/cleanRedirect.ts diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 459219c5..c369aef4 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -101,7 +101,8 @@ export async function verifyResourceSession( return allowed(res); } - const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + // const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}`; if (!sessions) { return notAllowed(res); diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index b8b5c6a9..fa41beb2 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -25,7 +25,7 @@ export default async function OrgLayout(props: { const user = await getUser(); if (!user) { - redirect(`/?redirect=/${orgId}`); + redirect(`/`); } try { diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 5923118e..4b41b8c3 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({ const user = await getUser(); if (!user) { - redirect(`/?redirect=/${orgId}/settings/general`); + redirect(`/`); } let orgUser = null; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 95a6cc00..b0b561a2 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -61,7 +61,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const user = await getUser(); if (!user) { - redirect(`/?redirect=/${params.orgId}/`); + redirect(`/`); } const cookie = await authCookieHeader(); diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/app/auth/login/DashboardLoginForm.tsx index 088fc631..715a0fb9 100644 --- a/src/app/auth/login/DashboardLoginForm.tsx +++ b/src/app/auth/login/DashboardLoginForm.tsx @@ -13,6 +13,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import Image from "next/image"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; type DashboardLoginFormProps = { redirect?: string; @@ -57,10 +58,9 @@ export default function DashboardLoginForm({ { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } else if (redirect) { - router.push(redirect); + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 87c27071..118cfcd0 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -5,6 +5,7 @@ import { cache } from "react"; import DashboardLoginForm from "./DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; export const dynamic = "force-dynamic"; @@ -25,6 +26,11 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined = undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect as string); + } + return ( <> {isInvite && ( @@ -42,16 +48,16 @@ export default async function Page(props: { )} - + {(!signUpDisabled || isInvite) && (

Don't have an account?{" "} diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index a9232d4d..ae997818 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { passwordSchema } from "@server/auth/passwordSchema"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; const requestSchema = z.object({ email: z.string().email() @@ -186,11 +187,9 @@ export default function ResetPasswordForm({ setSuccessMessage("Password reset successfully! Back to login..."); setTimeout(() => { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } if (redirect) { - router.push(redirect); + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/login"); } diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index b5636c42..73654beb 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import ResetPasswordForm from "./ResetPasswordForm"; import Link from "next/link"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; export const dynamic = "force-dynamic"; @@ -21,6 +22,11 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined = undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect); + } + return ( <> diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index a25edf74..c23403a8 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -481,11 +481,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 886801c7..4258f688 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -55,7 +55,17 @@ export default async function ResourceAuthPage(props: { ); } - const redirectUrl = searchParams.redirect || authInfo.url; + let redirectUrl = authInfo.url; + // if (searchParams.redirect) { + // try { + // const serverResourceHost = new URL(authInfo.url).host; + // const redirectHost = new URL(searchParams.redirect).host; + // + // if (serverResourceHost === redirectHost) { + // redirectUrl = searchParams.redirect; + // } + // } catch (e) {} + // } const hasAuth = authInfo.password || diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 9630d907..f839284e 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -30,6 +30,7 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import Image from "next/image"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; type SignupFormProps = { redirect?: string; @@ -92,17 +93,17 @@ export default function SignupForm({ if (res.data?.data?.emailVerificationRequired) { if (redirect) { - router.push(`/auth/verify-email?redirect=${redirect}`); + const safe = cleanRedirect(redirect); + router.push(`/auth/verify-email?redirect=${safe}`); } else { router.push("/auth/verify-email"); } return; } - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } else if (redirect) { - router.push(redirect); + if (redirect) { + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index f53ff2c8..361cc0db 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,5 +1,6 @@ import SignupForm from "@app/app/auth/signup/SignupForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { Mail } from "lucide-react"; import Link from "next/link"; @@ -41,6 +42,11 @@ export default async function Page(props: { } } + let redirectUrl: string | undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect); + } + return ( <> {isInvite && ( @@ -59,7 +65,7 @@ export default async function Page(props: { )} @@ -68,9 +74,9 @@ export default async function Page(props: { Already have an account?{" "} diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index 7a6bc082..8a0ca89a 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -36,6 +36,7 @@ import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/api";; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; const FormSchema = z.object({ email: z.string().email({ message: "Invalid email address" }), @@ -91,11 +92,9 @@ export default function VerifyEmailForm({ "Email successfully verified! Redirecting you..." ); setTimeout(() => { - if (redirect && redirect.includes("http")) { - window.location.href = redirect; - } if (redirect) { - router.push(redirect); + const safe = cleanRedirect(redirect); + router.push(safe); } else { router.push("/"); } diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 3452df69..033fa75d 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -1,5 +1,6 @@ import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm"; import { verifySession } from "@app/lib/auth/verifySession"; +import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { redirect } from "next/navigation"; import { cache } from "react"; @@ -27,11 +28,16 @@ export default async function Page(props: { redirect("/"); } + let redirectUrl: string | undefined; + if (searchParams.redirect) { + redirectUrl = cleanRedirect(searchParams.redirect as string); + } + return ( <> ); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 16ce9963..b4abbad3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,8 @@ import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { Separator } from "@app/components/ui/separator"; import { pullEnv } from "@app/lib/pullEnv"; +import { BookOpenText } from "lucide-react"; +import Image from "next/image"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -38,10 +40,10 @@ export default async function RootLayout({

{children}
{/* Footer */} -