diff --git a/Dockerfile b/Dockerfile index aeeafeb..4a54d92 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 c189579..d1d299b 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 0000000..770c30b --- /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 0000000..de104a2 --- /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 985b8b6..91d6701 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 0361c9f..bd76851 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 de104a2..3b4512d 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 5b1b25b..ed4e7f2 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 9bab34a..2ba0ab9 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 01b7dc2..4928733 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 4e0d77c..0e9e481 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 0000000..4fcfb11 --- /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 3d49228..849c376 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}`;