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>
This commit is contained in:
Milo Schwartz 2025-01-07 22:41:35 -05:00 committed by GitHub
parent a36691e5ab
commit 235e91294e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 193 additions and 51 deletions

1
.gitignore vendored
View file

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

View file

@ -1,5 +1,10 @@
build-all:
all: build push @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: build-arm:
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
@ -10,9 +15,6 @@ build-x86:
build: build:
docker build -t fosrl/pangolin:latest . docker build -t fosrl/pangolin:latest .
push:
docker push fosrl/pangolin:latest
test: test:
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest

View file

@ -123,4 +123,7 @@ Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license.
## Contributions ## 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.

View file

@ -1,5 +1,6 @@
app: app:
base_url: http://localhost dashboard_url: http://localhost
base_domain: localhost
log_level: debug log_level: debug
save_logs: false save_logs: false

View file

@ -2,12 +2,9 @@ version: "3.7"
services: services:
pangolin: pangolin:
image: fosrl/pangolin:1.0.0-beta.1 image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
ports:
- 3001:3001
- 3000:3000
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@ -17,7 +14,7 @@ services:
retries: 5 retries: 5
gerbil: gerbil:
image: fosrl/gerbil:1.0.0-beta.1 image: fosrl/gerbil:latest
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View file

@ -1,5 +1,6 @@
app: app:
base_url: https://{{.Domain}} dashboard_url: https://{{.Domain}}
base_domain: {{.Domain}}
log_level: info log_level: info
save_logs: false save_logs: false

View file

@ -1,11 +1,8 @@
services: services:
pangolin: pangolin:
image: fosrl/pangolin:1.0.0-beta.1 image: fosrl/pangolin:latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
ports:
- 3001:3001
- 3000:3000
volumes: volumes:
- ./config:/app/config - ./config:/app/config
healthcheck: healthcheck:
@ -15,7 +12,7 @@ services:
retries: 5 retries: 5
gerbil: gerbil:
image: fosrl/gerbil:1.0.0-beta.1 image: fosrl/gerbil:latest
container_name: gerbil container_name: gerbil
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.1", "version": "1.0.0-beta.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",

View file

@ -31,7 +31,7 @@ export function createApiServer() {
); );
} else { } else {
const corsOptions = { const corsOptions = {
origin: config.getRawConfig().app.base_url, origin: config.getRawConfig().app.dashboard_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"] allowedHeaders: ["Content-Type", "X-CSRF-Token"]
}; };

View file

@ -17,7 +17,7 @@ export async function sendEmailVerificationCode(
VerifyEmail({ VerifyEmail({
username: email, username: email,
verificationCode: code, verificationCode: code,
verifyLink: `${config.getRawConfig().app.base_url}/auth/verify-email` verifyLink: `${config.getRawConfig().app.dashboard_url}/auth/verify-email`
}), }),
{ {
to: email, to: email,

View file

@ -3,18 +3,25 @@ import yaml from "js-yaml";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; 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 { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
const portSchema = z.number().positive().gt(0).lte(65535); 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({ const environmentSchema = z.object({
app: z.object({ app: z.object({
base_url: z dashboard_url: z
.string() .string()
.url() .url()
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
base_domain: hostnameSchema,
log_level: z.enum(["debug", "info", "warn", "error"]), log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean() save_logs: z.boolean()
}), }),
@ -58,7 +65,7 @@ const environmentSchema = z.object({
smtp_port: portSchema, smtp_port: portSchema,
smtp_user: z.string(), smtp_user: z.string(),
smtp_pass: z.string(), smtp_pass: z.string(),
no_reply: z.string().email(), no_reply: z.string().email()
}) })
.optional(), .optional(),
users: z.object({ 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; let environment: any;
if (fs.existsSync(configFilePath1)) { if (fs.existsSync(configFilePath1)) {
environment = loadConfig(configFilePath1); environment = loadConfig(configFilePath1);
@ -190,15 +194,7 @@ export class Config {
} }
public getBaseDomain(): string { public getBaseDomain(): string {
const newUrl = new URL(this.rawConfig.app.base_url); return this.rawConfig.app.base_domain;
const hostname = newUrl.hostname;
const parts = hostname.split(".");
if (parts.length <= 2) {
return parts.join(".");
}
return parts.slice(1).join(".");
} }
} }

View file

@ -6,3 +6,6 @@ export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
export const APP_PATH = path.join("config"); 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");

View file

@ -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( await sendEmail(
ResetPasswordCode({ ResetPasswordCode({

View file

@ -101,7 +101,7 @@ export async function verifyResourceSession(
return allowed(res); 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) { if (!sessions) {
return notAllowed(res); return notAllowed(res);

View file

@ -82,7 +82,6 @@ export async function createOrg(
let org: Org | null = null; let org: Org | null = null;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// create a url from config.getRawConfig().app.base_url and get the hostname
const domain = config.getBaseDomain(); const domain = config.getBaseDomain();
const newOrg = await trx const newOrg = await trx

View file

@ -12,6 +12,34 @@ import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets"; 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 const createTargetParamsSchema = z
.object({ .object({
resourceId: z resourceId: z
@ -23,7 +51,7 @@ const createTargetParamsSchema = z
const createTargetSchema = z const createTargetSchema = z
.object({ .object({
ip: z.string().ip().or(z.literal('localhost')), ip: domainSchema,
method: z.string().min(1).max(10), method: z.string().min(1).max(10),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
protocol: z.string().optional(), protocol: z.string().optional(),

View file

@ -11,6 +11,34 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; 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 const updateTargetParamsSchema = z
.object({ .object({
targetId: z.string().transform(Number).pipe(z.number().int().positive()) targetId: z.string().transform(Number).pipe(z.number().int().positive())
@ -19,7 +47,7 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .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(), method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()

View file

@ -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) { if (doEmail) {
await sendEmail( await sendEmail(

View file

@ -5,7 +5,6 @@ import { eq, ne } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
export async function copyInConfig() { export async function copyInConfig() {
// create a url from config.getRawConfig().app.base_url and get the hostname
const domain = config.getBaseDomain(); const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint; const endpoint = config.getRawConfig().gerbil.base_endpoint;

View file

@ -7,13 +7,15 @@ import { desc } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts"; import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion"; import { loadAppVersion } from "@server/lib/loadAppVersion";
import m1 from "./scripts/1.0.0-beta1"; import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions // Define the migration list with versions and their corresponding functions
const migrations = [ 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 // Add new migrations here as they are created
] as const; ] as const;

View file

@ -1,7 +1,5 @@
import logger from "@server/logger";
export default async function migration() { 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 // SQL operations would go here in ts format
console.log("Done..."); console.log("Done.");
} }

View file

@ -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(".");
}

View file

@ -63,8 +63,36 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput"; 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({ const addTargetSchema = z.object({
ip: z.union([z.string().ip(), z.literal("localhost")]), ip: domainSchema,
method: z.string(), method: z.string(),
port: z.coerce.number().int().positive() port: z.coerce.number().int().positive()
// protocol: z.string(), // protocol: z.string(),
@ -179,7 +207,7 @@ export default function ReverseProxyTargets(props: {
// make sure that the target IP is within the site subnet // make sure that the target IP is within the site subnet
const targetIp = data.ip; const targetIp = data.ip;
const subnet = site.subnet; const subnet = site.subnet;
if (targetIp === "localhost" || !isIPInSubnet(targetIp, subnet)) { if (!isIPInSubnet(targetIp, subnet)) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Invalid target IP", title: "Invalid target IP",