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 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/docker-compose.example.yml b/docker-compose.example.yml index b736e94d..b6184c67 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -2,12 +2,9 @@ version: "3.7" services: pangolin: - image: fosrl/pangolin:1.0.0-beta.1 + image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped - ports: - - 3001:3001 - - 3000:3000 volumes: - ./config:/app/config healthcheck: @@ -17,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/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/install/fs/docker-compose.yml b/install/fs/docker-compose.yml index bf08aaf1..47fd82f8 100644 --- a/install/fs/docker-compose.yml +++ b/install/fs/docker-compose.yml @@ -1,11 +1,8 @@ services: pangolin: - image: fosrl/pangolin:1.0.0-beta.1 + image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped - ports: - - 3001:3001 - - 3000:3000 volumes: - ./config:/app/config healthcheck: @@ -15,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: 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/target/createTarget.ts b/server/routers/target/createTarget.ts index 742cdf68..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().or(z.literal('localhost')), + 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 3e288020..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().ip().or(z.literal('localhost')).optional(), // for now we cant update the ip; you will have to delete + 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/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("."); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 9b858835..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.union([z.string().ip(), z.literal("localhost")]), + ip: domainSchema, method: z.string(), port: z.coerce.number().int().positive() // protocol: z.string(), @@ -179,7 +207,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",