import fs from "fs"; 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 { 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 .string() .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]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/ ) .or(z.literal("localhost")); 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 .optional() .transform(getEnvOrYaml("APP_BASEDOMAIN")) .pipe(hostnameSchema) .transform((url) => url.toLowerCase()), log_level: z.enum(["debug", "info", "warn", "error"]), save_logs: z.boolean(), log_failed_attempts: z.boolean().optional() }), server: z.object({ 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()), session_cookie_name: z.string(), resource_access_token_param: z.string(), resource_session_request_param: z.string(), dashboard_session_length_hours: z .number() .positive() .gt(0) .optional() .default(720), resource_session_length_hours: z .number() .positive() .gt(0) .optional() .default(720), 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(), https_entrypoint: z.string().optional(), cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional(), additional_middlewares: z.array(z.string()).optional() }), gerbil: z.object({ 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), site_block_size: z.number().positive().gt(0) }), rate_limits: z.object({ global: z.object({ window_minutes: z.number().positive().gt(0), max_requests: z.number().positive().gt(0) }), auth: z .object({ window_minutes: z.number().positive().gt(0), max_requests: z.number().positive().gt(0) }) .optional() }), email: z .object({ smtp_host: z.string().optional(), smtp_port: portSchema.optional(), smtp_user: z.string().optional(), smtp_pass: z.string().optional(), smtp_secure: z.boolean().optional(), no_reply: z.string().email().optional() }) .optional(), users: z.object({ server_admin: z.object({ email: z .string() .email() .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL")) .pipe(z.string().email()) .transform((v) => v.toLowerCase()), password: passwordSchema .optional() .transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD")) .pipe(passwordSchema) }) }), flags: z .object({ require_email_verification: z.boolean().optional(), disable_signup_without_invite: z.boolean().optional(), disable_user_create_org: z.boolean().optional(), allow_raw_resources: z.boolean().optional() }) .optional() }); export class Config { 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 { const yamlContent = fs.readFileSync(configPath, "utf8"); const config = yaml.load(yamlContent); return config; } catch (error) { if (error instanceof Error) { throw new Error( `Error loading configuration file: ${error.message}` ); } throw error; } }; let environment: any; if (fs.existsSync(configFilePath1)) { environment = loadConfig(configFilePath1); } else if (fs.existsSync(configFilePath2)) { environment = loadConfig(configFilePath2); } if (!environment) { const exampleConfigPath = path.join( __DIRNAME, "config.example.yml" ); if (fs.existsSync(exampleConfigPath)) { try { const exampleConfigContent = fs.readFileSync( exampleConfigPath, "utf8" ); fs.writeFileSync( configFilePath1, exampleConfigContent, "utf8" ); 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: ${ error.message }` ); } throw error; } } else { throw new Error( "No configuration file found and no example configuration available" ); } } if (!environment) { throw new Error("No configuration file found"); } const parsedConfig = configSchema.safeParse(environment); if (!parsedConfig.success) { const errors = fromError(parsedConfig.error); throw new Error(`Invalid configuration file: ${errors}`); } const appVersion = loadAppVersion(); if (!appVersion) { throw new Error("Could not load the application version"); } process.env.APP_VERSION = appVersion; process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString(); process.env.SERVER_EXTERNAL_PORT = parsedConfig.data.server.external_port.toString(); process.env.SERVER_INTERNAL_PORT = parsedConfig.data.server.internal_port.toString(); process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags ?.require_email_verification ? "true" : "false"; process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags ?.allow_raw_resources ? "true" : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags ?.disable_signup_without_invite ? "true" : "false"; process.env.DISABLE_USER_CREATE_ORG = parsedConfig.data.flags ?.disable_user_create_org ? "true" : "false"; process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.data.server.resource_access_token_param; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.data.server.resource_session_request_param; this.rawConfig = parsedConfig.data; } public getRawConfig() { return this.rawConfig; } public getBaseDomain(): string { return this.rawConfig.app.base_domain; } public getNoReplyEmail(): string | undefined { return ( this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user ); } 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 default config;