From 7ff5376d134494fc43eedad71598fdc4756a9a1c Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 12 Jan 2025 20:42:16 -0500 Subject: [PATCH 1/5] log url to docs if config error --- server/lib/config.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index d480892b..2b19b304 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -3,7 +3,12 @@ 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, + APP_PATH, + configFilePath1, + configFilePath2 +} from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; @@ -132,6 +137,9 @@ export class Config { ); environment = loadConfig(configFilePath1); } catch (error) { + console.log( + "See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config" + ); if (error instanceof Error) { throw new Error( `Error creating configuration file from example: ${ @@ -187,7 +195,8 @@ export class Config { ?.disable_user_create_org ? "true" : "false"; - process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; + process.env.RESOURCE_ACCESS_TOKEN_PARAM = + parsedConfig.data.server.resource_access_token_param; this.rawConfig = parsedConfig.data; } From ab18e15a71bfeecc8a338abb31420f1c1974746e Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Mon, 13 Jan 2025 23:59:10 -0500 Subject: [PATCH 2/5] allow controlling cors from config and add cors middleware to traefik --- config/config.example.yml | 12 ++++--- install/fs/traefik/dynamic_config.yml | 22 ++++++++++++ server/apiServer.ts | 40 ++++++++++++--------- server/auth/sessions/app.ts | 50 +++++++++++++++++---------- server/lib/config.ts | 8 ++++- server/routers/auth/login.ts | 3 +- server/routers/auth/logout.ts | 3 +- server/routers/auth/signup.ts | 3 +- 8 files changed, 98 insertions(+), 43 deletions(-) diff --git a/config/config.example.yml b/config/config.example.yml index 69a0e06e..c1895798 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,15 +1,15 @@ app: - dashboard_url: http://localhost + dashboard_url: http://localhost:3002 base_domain: localhost - log_level: debug + log_level: info save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: localhost - secure_cookies: false + internal_hostname: pangolin + secure_cookies: true session_cookie_name: p_session resource_session_cookie_name: p_resource_session resource_access_token_param: p_token @@ -38,4 +38,6 @@ users: password: Password123! flags: - require_email_verification: false + require_email_verification: true + disable_signup_without_invite: true + disable_user_create_org: true diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index 770c30ba..0361c9f7 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -4,6 +4,21 @@ http: redirectScheme: scheme: https permanent: true + cors: + headers: + accessControlAllowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + accessControlAllowHeaders: + - Content-Type + - X-CSRF-Token + accessControlAllowOriginList: + - https://{{.DashboardDomain}} + accessControlAllowCredentials: false + routers: # HTTP to HTTPS redirect router @@ -14,6 +29,7 @@ http: - web middlewares: - redirect-to-https + - cors # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -21,6 +37,8 @@ http: service: next-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt @@ -30,6 +48,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt @@ -39,6 +59,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt diff --git a/server/apiServer.ts b/server/apiServer.ts index 27796be9..9bab34a2 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -20,23 +20,30 @@ const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { const apiServer = express(); - // Middleware setup apiServer.set("trust proxy", 1); - if (dev) { - apiServer.use( - cors({ - origin: `http://localhost:${config.getRawConfig().server.next_port}`, - credentials: true - }) - ); - } else { - const corsOptions = { - origin: config.getRawConfig().app.dashboard_url, - methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], - allowedHeaders: ["Content-Type", "X-CSRF-Token"] - }; - apiServer.use(cors(corsOptions)); + const corsConfig = config.getRawConfig().server.cors; + + const options = { + ...(corsConfig?.origins + ? { origin: corsConfig.origins } + : { + origin: (origin: any, callback: any) => { + callback(null, true); + } + }), + ...(corsConfig?.methods && { methods: corsConfig.methods }), + ...(corsConfig?.allowed_headers && { + allowedHeaders: corsConfig.allowed_headers + }), + credentials: !(corsConfig?.credentials === false) + }; + + logger.debug("Using CORS options", options); + + apiServer.use(cors(options)); + + if (!dev) { apiServer.use(helmet()); apiServer.use(csrfProtectionMiddleware); } @@ -47,7 +54,8 @@ export function createApiServer() { if (!dev) { apiServer.use( rateLimitMiddleware({ - windowMin: config.getRawConfig().rate_limits.global.window_minutes, + windowMin: + config.getRawConfig().rate_limits.global.window_minutes, max: config.getRawConfig().rate_limits.global.max_requests, type: "IP_AND_PATH" }) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 4bf2c40d..ac91fc1f 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -1,6 +1,6 @@ import { encodeBase32LowerCaseNoPadding, - encodeHexLowerCase, + encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { Session, sessions, User, users } from "@server/db/schema"; @@ -9,8 +9,10 @@ import { eq } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; +import logger from "@server/logger"; -export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; +export const SESSION_COOKIE_NAME = + config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const COOKIE_DOMAIN = "." + config.getBaseDomain(); @@ -24,25 +26,25 @@ export function generateSessionToken(): string { export async function createSession( token: string, - userId: string, + userId: string ): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), + sha256(new TextEncoder().encode(token)) ); const session: Session = { sessionId: sessionId, userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() }; await db.insert(sessions).values(session); return session; } export async function validateSessionToken( - token: string, + token: string ): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), + sha256(new TextEncoder().encode(token)) ); const result = await db .select({ user: users, session: sessions }) @@ -61,12 +63,12 @@ export async function validateSessionToken( } if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { session.expiresAt = new Date( - Date.now() + SESSION_COOKIE_EXPIRES, + Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); await db .update(sessions) .set({ - expiresAt: session.expiresAt, + expiresAt: session.expiresAt }) .where(eq(sessions.sessionId, session.sessionId)); } @@ -81,26 +83,38 @@ export async function invalidateAllSessions(userId: string): Promise { await db.delete(sessions).where(eq(sessions.userId, userId)); } -export function serializeSessionCookie(token: string): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; +export function serializeSessionCookie( + token: string, + isSecure: boolean +): string { + if (isSecure) { + logger.debug("Setting cookie for secure origin"); + if (SECURE_COOKIES) { + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + } else { + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + } } else { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; } } -export function createBlankSessionTokenCookie(): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; +export function createBlankSessionTokenCookie(isSecure: boolean): string { + if (isSecure) { + if (SECURE_COOKIES) { + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + } else { + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; + } } else { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`; } } const random: RandomReader = { read(bytes: Uint8Array): void { crypto.getRandomValues(bytes); - }, + } }; export function generateId(length: number): string { diff --git a/server/lib/config.ts b/server/lib/config.ts index 2b19b304..01b7dc24 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -38,7 +38,13 @@ const environmentSchema = z.object({ secure_cookies: z.boolean(), 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({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional(), + }).optional() }), traefik: z.object({ http_entrypoint: z.string(), diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index f5c28ad5..328caeb9 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -120,7 +120,8 @@ export async function login( const token = generateSessionToken(); await createSession(token, existingUser.userId); - const cookie = serializeSessionCookie(token); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie(token, isSecure); res.appendHeader("Set-Cookie", cookie); diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index 29ca18f5..3b466767 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -27,7 +27,8 @@ export async function logout( try { await invalidateSession(sessionId); - res.setHeader("Set-Cookie", createBlankSessionTokenCookie()); + const isSecure = req.protocol === "https"; + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); return response(res, { data: null, diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 1bc2498e..9710d858 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -158,7 +158,8 @@ export async function signup( const token = generateSessionToken(); await createSession(token, userId); - const cookie = serializeSessionCookie(token); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie(token, isSecure); res.appendHeader("Set-Cookie", cookie); if (config.getRawConfig().flags?.require_email_verification) { From 4b5c74e8d6dc92adae60281864c995da831f9fdb Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Wed, 15 Jan 2025 21:37:10 -0500 Subject: [PATCH 3/5] Import start port at startup for now for exit node --- install/main.go | 4 ++-- server/routers/gerbil/getConfig.ts | 4 +++- server/setup/copyInConfig.ts | 3 +++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/install/main.go b/install/main.go index ae598033..3267c398 100644 --- a/install/main.go +++ b/install/main.go @@ -18,8 +18,8 @@ import ( ) func loadVersions(config *Config) { - config.PangolinVersion = "1.0.0-beta.5" - config.GerbilVersion = "1.0.0-beta.1" + config.PangolinVersion = "1.0.0-beta.6" + config.GerbilVersion = "1.0.0-beta.2" } //go:embed fs/* diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index 27fb05d0..314e715a 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -50,7 +50,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction) let exitNode; if (exitNodeQuery.length === 0) { const address = await getNextAvailableSubnet(); - const listenPort = await getNextAvailablePort(); + // TODO: eventually we will want to get the next available port so that we can multiple exit nodes + // const listenPort = await getNextAvailablePort(); + const listenPort = config.getRawConfig().gerbil.start_port; let subEndpoint = ""; if (config.getRawConfig().gerbil.use_subdomain) { subEndpoint = await getUniqueExitNodeEndpointName(); diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 0ff3ba7f..5a5e6711 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -7,6 +7,7 @@ import logger from "@server/logger"; export async function copyInConfig() { const domain = config.getBaseDomain(); const endpoint = config.getRawConfig().gerbil.base_endpoint; + const listenPort = config.getRawConfig().gerbil.start_port; // update the domain on all of the orgs where the domain is not equal to the new domain // TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary @@ -14,6 +15,8 @@ export async function copyInConfig() { // TODO: eventually each exit node could have a different endpoint await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint)); + // TODO: eventually each exit node could have a different port + await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort)); // update all resources fullDomain to use the new domain await db.transaction(async (trx) => { From 1aec431c36d5877d1a7546228f0cdfba3d7e5cf0 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 15 Jan 2025 23:26:31 -0500 Subject: [PATCH 4/5] optionally generate traefik files, set cors in config, and set trust proxy in config --- Dockerfile | 2 + config/config.example.yml | 2 +- config/traefik/dynamic_config.example.yml | 54 +++++++ config/traefik/traefik_config.example.yml | 41 +++++ install/fs/config.yml | 5 + install/fs/traefik/dynamic_config.yml | 26 +--- install/fs/traefik/traefik_config.yml | 2 +- package.json | 2 +- server/apiServer.ts | 4 +- server/lib/config.ts | 147 ++++++++++++++++-- server/setup/migrations.ts | 4 +- server/setup/scripts/1.0.0-beta6.ts | 52 +++++++ .../[orgId]/settings/sites/CreateSiteForm.tsx | 8 +- 13 files changed, 300 insertions(+), 49 deletions(-) create mode 100644 config/traefik/dynamic_config.example.yml create mode 100644 config/traefik/traefik_config.example.yml create mode 100644 server/setup/scripts/1.0.0-beta6.ts diff --git a/Dockerfile b/Dockerfile index aeeafeb1..4a54d925 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init 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 public ./public diff --git a/config/config.example.yml b/config/config.example.yml index c1895798..d1d299b7 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -38,6 +38,6 @@ users: password: Password123! flags: - require_email_verification: true + require_email_verification: false disable_signup_without_invite: true disable_user_create_org: true diff --git a/config/traefik/dynamic_config.example.yml b/config/traefik/dynamic_config.example.yml new file mode 100644 index 00000000..770c30ba --- /dev/null +++ b/config/traefik/dynamic_config.example.yml @@ -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 diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml new file mode 100644 index 00000000..de104a2f --- /dev/null +++ b/config/traefik/traefik_config.example.yml @@ -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 diff --git a/install/fs/config.yml b/install/fs/config.yml index 985b8b62..91d67019 100644 --- a/install/fs/config.yml +++ b/install/fs/config.yml @@ -13,6 +13,11 @@ server: session_cookie_name: p_session resource_session_cookie_name: p_resource_session 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: cert_resolver: letsencrypt diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index 0361c9f7..bd76851b 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -4,21 +4,6 @@ http: redirectScheme: scheme: https permanent: true - cors: - headers: - accessControlAllowMethods: - - GET - - PUT - - POST - - DELETE - - PATCH - accessControlAllowHeaders: - - Content-Type - - X-CSRF-Token - accessControlAllowOriginList: - - https://{{.DashboardDomain}} - accessControlAllowCredentials: false - routers: # HTTP to HTTPS redirect router @@ -29,7 +14,6 @@ http: - web middlewares: - redirect-to-https - - cors # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -37,8 +21,6 @@ http: service: next-service entryPoints: - websecure - middlewares: - - cors tls: certResolver: letsencrypt @@ -48,8 +30,6 @@ http: service: api-service entryPoints: - websecure - middlewares: - - cors tls: certResolver: letsencrypt @@ -59,8 +39,6 @@ http: service: api-service entryPoints: - websecure - middlewares: - - cors tls: certResolver: letsencrypt @@ -68,9 +46,9 @@ http: next-service: loadBalancer: servers: - - url: "http://pangolin:3002" # Next.js server + - url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server api-service: loadBalancer: servers: - - url: "http://pangolin:3000" # API/WebSocket server + - url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index de104a2f..3b4512d5 100644 --- a/install/fs/traefik/traefik_config.yml +++ b/install/fs/traefik/traefik_config.yml @@ -4,7 +4,7 @@ api: providers: http: - endpoint: "http://pangolin:3001/api/v1/traefik-config" + endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" diff --git a/package.json b/package.json index 5b1b25b0..ed4e7f27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fosrl/pangolin", - "version": "1.0.0-beta.5", + "version": "1.0.0-beta.6", "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 9bab34a2..2ba0ab92 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -20,7 +20,9 @@ const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { 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; diff --git a/server/lib/config.ts b/server/lib/config.ts index 01b7dc24..49287339 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -11,6 +11,7 @@ import { } from "@server/lib/consts"; import { loadAppVersion } from "@server/lib/loadAppVersion"; import { passwordSchema } from "@server/auth/passwordSchema"; +import stoi from "./stoi"; const portSchema = z.number().positive().gt(0).lte(65535); const hostnameSchema = z @@ -20,31 +21,56 @@ const hostnameSchema = z ) .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({ dashboard_url: z .string() .url() + .optional() + .transform(getEnvOrYaml("APP_DASHBOARDURL")) + .pipe(z.string().url()) .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"]), save_logs: z.boolean() }), server: z.object({ - external_port: portSchema, - internal_port: portSchema, - next_port: portSchema, + external_port: portSchema + .optional() + .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()), secure_cookies: z.boolean(), session_cookie_name: z.string(), resource_session_cookie_name: z.string(), resource_access_token_param: z.string(), - cors: z.object({ - origins: z.array(z.string()).optional(), - methods: z.array(z.string()).optional(), - allowed_headers: z.array(z.string()).optional(), - credentials: z.boolean().optional(), - }).optional() + cors: z + .object({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional() + }) + .optional(), + trust_proxy: z.boolean().optional().default(true) }), traefik: z.object({ http_entrypoint: z.string(), @@ -53,8 +79,17 @@ const environmentSchema = z.object({ prefer_wildcard_cert: z.boolean().optional() }), gerbil: z.object({ - start_port: portSchema, - base_endpoint: z.string().transform((url) => url.toLowerCase()), + start_port: portSchema + .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(), subnet_group: z.string(), block_size: z.number().positive().gt(0), @@ -83,8 +118,16 @@ const environmentSchema = z.object({ .optional(), users: 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 + .optional() + .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD")) + .pipe(passwordSchema) }) }), flags: z @@ -97,12 +140,18 @@ const environmentSchema = z.object({ }); export class Config { - private rawConfig!: z.infer; + private rawConfig!: z.infer; constructor() { this.loadConfig(); + + if (process.env.GENERATE_TRAEFIK_CONFIG === "true") { + this.createTraefikConfig(); + } } + public loadEnvironment() {} + public loadConfig() { const loadConfig = (configPath: string) => { try { @@ -166,7 +215,7 @@ export class Config { throw new Error("No configuration file found"); } - const parsedConfig = environmentSchema.safeParse(environment); + const parsedConfig = configSchema.safeParse(environment); if (!parsedConfig.success) { const errors = fromError(parsedConfig.error); @@ -214,6 +263,72 @@ export class Config { public getBaseDomain(): string { 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(); diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 4e0d77ca..0e9e4819 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -10,6 +10,7 @@ import m1 from "./scripts/1.0.0-beta1"; import m2 from "./scripts/1.0.0-beta2"; import m3 from "./scripts/1.0.0-beta3"; import m4 from "./scripts/1.0.0-beta5"; +import m5 from "./scripts/1.0.0-beta6"; import { existsSync, mkdirSync } from "fs"; // 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.2", run: m2 }, { 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 ] as const; diff --git a/server/setup/scripts/1.0.0-beta6.ts b/server/setup/scripts/1.0.0-beta6.ts new file mode 100644 index 00000000..4fcfb114 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta6.ts @@ -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."); +} diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index 3d49228f..849c376f 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -235,10 +235,10 @@ PersistentKeepalive = 5` : ""; // am I at http or https? - let proto = "http:"; - if (typeof window !== "undefined") { - proto = window.location.protocol; - } + let proto = "https:"; + // if (typeof window !== "undefined") { + // proto = window.location.protocol; + // } const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${proto}//${siteDefaults?.endpoint}`; From ed5e6ec0f72283d497e71817db265e1fdf7776d9 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 15 Jan 2025 23:36:32 -0500 Subject: [PATCH 5/5] add port templates to traefik example files --- config/traefik/dynamic_config.example.yml | 4 ++-- config/traefik/traefik_config.example.yml | 2 +- install/fs/traefik/dynamic_config.yml | 4 ++-- install/fs/traefik/traefik_config.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/traefik/dynamic_config.example.yml b/config/traefik/dynamic_config.example.yml index 770c30ba..bd76851b 100644 --- a/config/traefik/dynamic_config.example.yml +++ b/config/traefik/dynamic_config.example.yml @@ -46,9 +46,9 @@ http: next-service: loadBalancer: servers: - - url: "http://pangolin:3002" # Next.js server + - url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server api-service: loadBalancer: servers: - - url: "http://pangolin:3000" # API/WebSocket server + - url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml index de104a2f..3b4512d5 100644 --- a/config/traefik/traefik_config.example.yml +++ b/config/traefik/traefik_config.example.yml @@ -4,7 +4,7 @@ api: providers: http: - endpoint: "http://pangolin:3001/api/v1/traefik-config" + endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index bd76851b..770c30ba 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -46,9 +46,9 @@ http: next-service: loadBalancer: servers: - - url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server + - url: "http://pangolin:3002" # Next.js server api-service: loadBalancer: servers: - - url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server + - url: "http://pangolin:3000" # API/WebSocket server diff --git a/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml index 3b4512d5..de104a2f 100644 --- a/install/fs/traefik/traefik_config.yml +++ b/install/fs/traefik/traefik_config.yml @@ -4,7 +4,7 @@ api: providers: http: - endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config" + endpoint: "http://pangolin:3001/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml"