Quellcode durchsuchen

remove `base_url` from config (#13)

* add example config dir, logos, and update CONTRIBUTING.md

* update dockerignore

* split base_url into dashboard_url and base_domain

* Remove unessicary ports

* Allow anything for the ip

* Update docker tags

* Complex regex for domains/ips

* update gitignore

---------

Co-authored-by: Owen Schwartz <owen@txv.io>
Milo Schwartz vor 6 Monaten
Ursprung
Commit
235e91294e

+ 1 - 0
.gitignore

@@ -29,3 +29,4 @@ config/config.yml
 dist
 .dist
 installer
+*.tar

+ 8 - 6
Makefile

@@ -1,18 +1,20 @@
-
-all: build push
+build-all:
+	@if [ -z "$(tag)" ]; then \
+		echo "Error: tag is required. Usage: make build-all tag=<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
 

+ 4 - 1
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.

+ 2 - 1
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
 

+ 2 - 5
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:

+ 2 - 1
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
 

+ 2 - 5
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:

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

+ 1 - 1
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"]
         };

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

+ 11 - 15
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}(?<!-)(\.[a-zA-Z]{2,})*$/,
+        "Invalid hostname. Must be a valid hostname like 'localhost' or 'test.example.com'."
+    );
 
 const environmentSchema = z.object({
     app: z.object({
-        base_url: z
+        dashboard_url: z
             .string()
             .url()
             .transform((url) => 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;
     }
 }
 

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

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

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

+ 0 - 1
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

+ 29 - 1
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(),

+ 29 - 1
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()

+ 1 - 1
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(

+ 0 - 1
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;
 

+ 3 - 1
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;
 

+ 2 - 4
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.");
 }

+ 59 - 0
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(".");
+}

+ 30 - 2
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",