optionally generate traefik files, set cors in config, and set trust proxy in config

This commit is contained in:
Milo Schwartz 2025-01-15 23:26:31 -05:00
parent cb87463a69
commit 1aec431c36
No known key found for this signature in database
13 changed files with 300 additions and 49 deletions

View file

@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init COPY --from=builder /app/init ./dist/init
COPY config/config.example.yml ./dist/config.example.yml COPY config/config.example.yml ./dist/config.example.yml
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
COPY server/db/names.json ./dist/names.json COPY server/db/names.json ./dist/names.json
COPY public ./public COPY public ./public

View file

@ -38,6 +38,6 @@ users:
password: Password123! password: Password123!
flags: flags:
require_email_verification: true require_email_verification: false
disable_signup_without_invite: true disable_signup_without_invite: true
disable_user_create_org: true disable_user_create_org: true

View file

@ -0,0 +1,54 @@
http:
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
routers:
# HTTP to HTTPS redirect router
main-app-router-redirect:
rule: "Host(`{{.DashboardDomain}}`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
# Next.js router (handles everything except API and WebSocket paths)
next-router:
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# API router (handles /api/v1 paths)
api-router:
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# WebSocket router
ws-router:
rule: "Host(`{{.DashboardDomain}}`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002" # Next.js server
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000" # API/WebSocket server

View file

@ -0,0 +1,41 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
log:
level: "INFO"
format: "common"
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
http:
tls:
certResolver: "letsencrypt"
serversTransport:
insecureSkipVerify: true

View file

@ -13,6 +13,11 @@ server:
session_cookie_name: p_session session_cookie_name: p_session
resource_session_cookie_name: p_resource_session resource_session_cookie_name: p_resource_session
resource_access_token_param: p_token resource_access_token_param: p_token
cors:
origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt

View file

@ -4,21 +4,6 @@ http:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true permanent: true
cors:
headers:
accessControlAllowMethods:
- GET
- PUT
- POST
- DELETE
- PATCH
accessControlAllowHeaders:
- Content-Type
- X-CSRF-Token
accessControlAllowOriginList:
- https://{{.DashboardDomain}}
accessControlAllowCredentials: false
routers: routers:
# HTTP to HTTPS redirect router # HTTP to HTTPS redirect router
@ -29,7 +14,6 @@ http:
- web - web
middlewares: middlewares:
- redirect-to-https - redirect-to-https
- cors
# Next.js router (handles everything except API and WebSocket paths) # Next.js router (handles everything except API and WebSocket paths)
next-router: next-router:
@ -37,8 +21,6 @@ http:
service: next-service service: next-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@ -48,8 +30,6 @@ http:
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@ -59,8 +39,6 @@ http:
service: api-service service: api-service
entryPoints: entryPoints:
- websecure - websecure
middlewares:
- cors
tls: tls:
certResolver: letsencrypt certResolver: letsencrypt
@ -68,9 +46,9 @@ http:
next-service: next-service:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3002" # Next.js server - url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
api-service: api-service:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3000" # API/WebSocket server - url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server

View file

@ -4,7 +4,7 @@ api:
providers: providers:
http: http:
endpoint: "http://pangolin:3001/api/v1/traefik-config" endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
pollInterval: "5s" pollInterval: "5s"
file: file:
filename: "/etc/traefik/dynamic_config.yml" filename: "/etc/traefik/dynamic_config.yml"

View file

@ -1,6 +1,6 @@
{ {
"name": "@fosrl/pangolin", "name": "@fosrl/pangolin",
"version": "1.0.0-beta.5", "version": "1.0.0-beta.6",
"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

@ -20,7 +20,9 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() { export function createApiServer() {
const apiServer = express(); const apiServer = express();
apiServer.set("trust proxy", 1); if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1);
}
const corsConfig = config.getRawConfig().server.cors; const corsConfig = config.getRawConfig().server.cors;

View file

@ -11,6 +11,7 @@ import {
} from "@server/lib/consts"; } 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";
import stoi from "./stoi";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z const hostnameSchema = z
@ -20,31 +21,56 @@ const hostnameSchema = z
) )
.or(z.literal("localhost")); .or(z.literal("localhost"));
const environmentSchema = z.object({ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
const configSchema = z.object({
app: z.object({ app: z.object({
dashboard_url: z dashboard_url: z
.string() .string()
.url() .url()
.optional()
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
base_domain: hostnameSchema, base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(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()
}), }),
server: z.object({ server: z.object({
external_port: portSchema, external_port: portSchema
internal_port: portSchema, .optional()
next_port: portSchema, .transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
.transform(stoi)
.pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()), internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
resource_session_cookie_name: z.string(), resource_session_cookie_name: z.string(),
resource_access_token_param: z.string(), resource_access_token_param: z.string(),
cors: z.object({ cors: z
origins: z.array(z.string()).optional(), .object({
methods: z.array(z.string()).optional(), origins: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(), methods: z.array(z.string()).optional(),
credentials: z.boolean().optional(), allowed_headers: z.array(z.string()).optional(),
}).optional() credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
}), }),
traefik: z.object({ traefik: z.object({
http_entrypoint: z.string(), http_entrypoint: z.string(),
@ -53,8 +79,17 @@ const environmentSchema = z.object({
prefer_wildcard_cert: z.boolean().optional() prefer_wildcard_cert: z.boolean().optional()
}), }),
gerbil: z.object({ gerbil: z.object({
start_port: portSchema, start_port: portSchema
base_endpoint: z.string().transform((url) => url.toLowerCase()), .optional()
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(), use_subdomain: z.boolean(),
subnet_group: z.string(), subnet_group: z.string(),
block_size: z.number().positive().gt(0), block_size: z.number().positive().gt(0),
@ -83,8 +118,16 @@ const environmentSchema = z.object({
.optional(), .optional(),
users: z.object({ users: z.object({
server_admin: z.object({ server_admin: z.object({
email: z.string().email(), email: z
.string()
.email()
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email()),
password: passwordSchema password: passwordSchema
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
.pipe(passwordSchema)
}) })
}), }),
flags: z flags: z
@ -97,12 +140,18 @@ const environmentSchema = z.object({
}); });
export class Config { export class Config {
private rawConfig!: z.infer<typeof environmentSchema>; private rawConfig!: z.infer<typeof configSchema>;
constructor() { constructor() {
this.loadConfig(); this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
} }
public loadEnvironment() {}
public loadConfig() { public loadConfig() {
const loadConfig = (configPath: string) => { const loadConfig = (configPath: string) => {
try { try {
@ -166,7 +215,7 @@ export class Config {
throw new Error("No configuration file found"); throw new Error("No configuration file found");
} }
const parsedConfig = environmentSchema.safeParse(environment); const parsedConfig = configSchema.safeParse(environment);
if (!parsedConfig.success) { if (!parsedConfig.success) {
const errors = fromError(parsedConfig.error); const errors = fromError(parsedConfig.error);
@ -214,6 +263,72 @@ export class Config {
public getBaseDomain(): string { public getBaseDomain(): string {
return this.rawConfig.app.base_domain; return this.rawConfig.app.base_domain;
} }
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
const defaultTraefikConfigPath = path.join(
__DIRNAME,
"traefik_config.example.yml"
);
const defaultDynamicConfigPath = path.join(
__DIRNAME,
"dynamic_config.example.yml"
);
const traefikPath = path.join(APP_PATH, "traefik");
if (!fs.existsSync(traefikPath)) {
return;
}
// load default configs
let traefikConfig = fs.readFileSync(
defaultTraefikConfigPath,
"utf8"
);
let dynamicConfig = fs.readFileSync(
defaultDynamicConfigPath,
"utf8"
);
traefikConfig = traefikConfig
.split("{{.LetsEncryptEmail}}")
.join(this.rawConfig.users.server_admin.email);
traefikConfig = traefikConfig
.split("{{.INTERNAL_PORT}}")
.join(this.rawConfig.server.internal_port.toString());
dynamicConfig = dynamicConfig
.split("{{.DashboardDomain}}")
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
dynamicConfig = dynamicConfig
.split("{{.NEXT_PORT}}")
.join(this.rawConfig.server.next_port.toString());
dynamicConfig = dynamicConfig
.split("{{.EXTERNAL_PORT}}")
.join(this.rawConfig.server.external_port.toString());
// write thiese to the traefik directory
const traefikConfigPath = path.join(
traefikPath,
"traefik_config.yml"
);
const dynamicConfigPath = path.join(
traefikPath,
"dynamic_config.yml"
);
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
console.log("Traefik configuration files created");
} catch (e) {
console.log(
"Failed to generate the Traefik configuration files. Please create them manually."
);
console.error(e);
}
}
} }
export const config = new Config(); export const config = new Config();

View file

@ -10,6 +10,7 @@ import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2"; import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3"; import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5"; import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import { existsSync, mkdirSync } from "fs"; import { existsSync, mkdirSync } from "fs";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
@ -20,7 +21,8 @@ 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 }, { version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 }, { version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 } { version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,52 @@
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.6...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
// Validate the structure
if (!rawConfig.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.cors = {
origins: [rawConfig.app.dashboard_url],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
headers: ["X-CSRF-Token", "Content-Type"],
credentials: false
};
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (error) {
console.log("We were unable to add CORS to your config file. Please add it manually.")
console.error(error)
}
console.log("Done.");
}

View file

@ -235,10 +235,10 @@ PersistentKeepalive = 5`
: ""; : "";
// am I at http or https? // am I at http or https?
let proto = "http:"; let proto = "https:";
if (typeof window !== "undefined") { // if (typeof window !== "undefined") {
proto = window.location.protocol; // proto = window.location.protocol;
} // }
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`; const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`;