diff --git a/install/main.go b/install/main.go index ca68a769..55eab5e3 100644 --- a/install/main.go +++ b/install/main.go @@ -52,6 +52,7 @@ type Config struct { TraefikBouncerKey string DoCrowdsecInstall bool Secret string + HybridMode bool } type SupportedContainer string @@ -70,9 +71,6 @@ func main() { fmt.Println("") fmt.Println("Please make sure you have the following prerequisites:") fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.") - fmt.Println("- Point your domain to the VPS IP with A records.") - fmt.Println("") - fmt.Println("https://docs.digpangolin.com/self-host/dns-and-networking") fmt.Println("") fmt.Println("Lets get started!") fmt.Println("") @@ -89,71 +87,8 @@ func main() { } reader := bufio.NewReader(os.Stdin) - inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") - - chosenContainer := Docker - if strings.EqualFold(inputContainer, "docker") { - chosenContainer = Docker - } else if strings.EqualFold(inputContainer, "podman") { - chosenContainer = Podman - } else { - fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer) - os.Exit(1) - } - - if chosenContainer == Podman { - if !isPodmanInstalled() { - fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") - os.Exit(1) - } - - if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { - fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") - fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") - approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) - if approved { - if os.Geteuid() != 0 { - fmt.Println("You need to run the installer as root for such a configuration.") - os.Exit(1) - } - - // Podman containers are not able to listen on privileged ports. The official recommendation is to - // container low-range ports as unprivileged ports. - // Linux only. - - if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { - fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err) - os.Exit(1) - } - } else { - fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.") - } - } else { - fmt.Println("Unprivileged ports have been configured.") - } - - } else if chosenContainer == Docker { - // check if docker is not installed and the user is root - if !isDockerInstalled() { - if os.Geteuid() != 0 { - fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") - os.Exit(1) - } - } - - // check if the user is in the docker group (linux only) - if !isUserInDockerGroup() { - fmt.Println("You are not in the docker group.") - fmt.Println("The installer will not be able to run docker commands without running it as root.") - os.Exit(1) - } - } else { - // This shouldn't happen unless there's a third container runtime. - os.Exit(1) - } var config Config - config.InstallationContainerType = chosenContainer // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { @@ -170,7 +105,9 @@ func main() { moveFile("config/docker-compose.yml", "docker-compose.yml") - if !isDockerInstalled() && runtime.GOOS == "linux" && chosenContainer == Docker { + config.InstallationContainerType = podmanOrDocker(reader) + + if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() // try to start docker service but ignore errors @@ -199,15 +136,15 @@ func main() { fmt.Println("\n=== Starting installation ===") - if (isDockerInstalled() && chosenContainer == Docker) || - (isPodmanInstalled() && chosenContainer == Podman) { + if (isDockerInstalled() && config.InstallationContainerType == Docker) || + (isPodmanInstalled() && config.InstallationContainerType == Podman) { if readBool(reader, "Would you like to install and start the containers?", true) { - if err := pullContainers(chosenContainer); err != nil { + if err := pullContainers(config.InstallationContainerType); err != nil { fmt.Println("Error: ", err) return } - if err := startContainers(chosenContainer); err != nil { + if err := startContainers(config.InstallationContainerType); err != nil { fmt.Println("Error: ", err) return } @@ -288,22 +225,89 @@ func main() { // Check if containers were started during this installation containersStarted := false - if (isDockerInstalled() && chosenContainer == Docker) || - (isPodmanInstalled() && chosenContainer == Podman) { + if (isDockerInstalled() && config.InstallationContainerType == Docker) || + (isPodmanInstalled() && config.InstallationContainerType == Podman) { // Try to fetch and display the token if containers are running containersStarted = true - printSetupToken(chosenContainer, config.DashboardDomain) + printSetupToken(config.InstallationContainerType, config.DashboardDomain) } // If containers weren't started or token wasn't found, show instructions if !containersStarted { - showSetupTokenInstructions(chosenContainer, config.DashboardDomain) + showSetupTokenInstructions(config.InstallationContainerType, config.DashboardDomain) } fmt.Println("Installation complete!") fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } +func podmanOrDocker(reader *bufio.Reader) SupportedContainer { + inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") + + chosenContainer := Docker + if strings.EqualFold(inputContainer, "docker") { + chosenContainer = Docker + } else if strings.EqualFold(inputContainer, "podman") { + chosenContainer = Podman + } else { + fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer) + os.Exit(1) + } + + if chosenContainer == Podman { + if !isPodmanInstalled() { + fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") + os.Exit(1) + } + + if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { + fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") + fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") + approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + if approved { + if os.Geteuid() != 0 { + fmt.Println("You need to run the installer as root for such a configuration.") + os.Exit(1) + } + + // Podman containers are not able to listen on privileged ports. The official recommendation is to + // container low-range ports as unprivileged ports. + // Linux only. + + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { + fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err) + os.Exit(1) + } + } else { + fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.") + } + } else { + fmt.Println("Unprivileged ports have been configured.") + } + + } else if chosenContainer == Docker { + // check if docker is not installed and the user is root + if !isDockerInstalled() { + if os.Geteuid() != 0 { + fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") + os.Exit(1) + } + } + + // check if the user is in the docker group (linux only) + if !isUserInDockerGroup() { + fmt.Println("You are not in the docker group.") + fmt.Println("The installer will not be able to run docker commands without running it as root.") + os.Exit(1) + } + } else { + // This shouldn't happen unless there's a third container runtime. + os.Exit(1) + } + + return chosenContainer +} + func readString(reader *bufio.Reader, prompt string, defaultValue string) string { if defaultValue != "" { fmt.Printf("%s (default: %s): ", prompt, defaultValue) @@ -318,6 +322,12 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string return input } +func readStringNoDefault(reader *bufio.Reader, prompt string) string { + fmt.Print(prompt + ": ") + input, _ := reader.ReadString('\n') + return strings.TrimSpace(input) +} + func readPassword(prompt string, reader *bufio.Reader) string { if term.IsTerminal(int(syscall.Stdin)) { fmt.Print(prompt + ": ") @@ -347,6 +357,11 @@ func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { return strings.ToLower(input) == "yes" } +func readBoolNoDefault(reader *bufio.Reader, prompt string) bool { + input := readStringNoDefault(reader, prompt+" (yes/no)") + return strings.ToLower(input) == "yes" +} + func readInt(reader *bufio.Reader, prompt string, defaultValue int) int { input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue)) if input == "" { @@ -362,42 +377,50 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") - config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") + config.HybridMode = readBoolNoDefault(reader, "Do you want to use hybrid mode?") - // Set default dashboard domain after base domain is collected - defaultDashboardDomain := "" - if config.BaseDomain != "" { - defaultDashboardDomain = "pangolin." + config.BaseDomain + if !config.HybridMode { + config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") + + // Set default dashboard domain after base domain is collected + defaultDashboardDomain := "" + if config.BaseDomain != "" { + defaultDashboardDomain = "pangolin." + config.BaseDomain + } + config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain) + config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") } - config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain) - config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") + config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) - // Email configuration - fmt.Println("\n=== Email Configuration ===") - config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) + if !config.HybridMode { + // Email configuration + fmt.Println("\n=== Email Configuration ===") + config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) - if config.EnableEmail { - config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") - config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) - config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") - config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? - config.EmailNoReply = readString(reader, "Enter no-reply email address", "") - } + if config.EnableEmail { + config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") + config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) + config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") + config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? + config.EmailNoReply = readString(reader, "Enter no-reply email address", "") + } - // Validate required fields - if config.BaseDomain == "" { - fmt.Println("Error: Domain name is required") - os.Exit(1) - } - if config.DashboardDomain == "" { - fmt.Println("Error: Dashboard Domain name is required") - os.Exit(1) - } - if config.LetsEncryptEmail == "" { - fmt.Println("Error: Let's Encrypt email is required") - os.Exit(1) + + // Validate required fields + if config.BaseDomain == "" { + fmt.Println("Error: Domain name is required") + os.Exit(1) + } + if config.DashboardDomain == "" { + fmt.Println("Error: Dashboard Domain name is required") + os.Exit(1) + } + if config.LetsEncryptEmail == "" { + fmt.Println("Error: Let's Encrypt email is required") + os.Exit(1) + } } return config diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 34d584f6..514bee00 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -24,8 +24,8 @@ export const SESSION_COOKIE_EXPIRES = 60 * 60 * config.getRawConfig().server.dashboard_session_length_hours; -export const COOKIE_DOMAIN = - "." + new URL(config.getRawConfig().app.dashboard_url).hostname; +export const COOKIE_DOMAIN = config.getRawConfig().app.dashboard_url ? + "." + new URL(config.getRawConfig().app.dashboard_url!).hostname : undefined; export function generateSessionToken(): string { const bytes = new Uint8Array(20); diff --git a/server/emails/index.ts b/server/emails/index.ts index 42cfa39c..0388d3bb 100644 --- a/server/emails/index.ts +++ b/server/emails/index.ts @@ -6,6 +6,11 @@ import logger from "@server/logger"; import SMTPTransport from "nodemailer/lib/smtp-transport"; function createEmailClient() { + if (config.isHybridMode()) { + // LETS NOT WORRY ABOUT EMAILS IN HYBRID + return; + } + const emailConfig = config.getRawConfig().email; if (!emailConfig) { logger.warn( diff --git a/server/lib/config.ts b/server/lib/config.ts index 82932441..2437eaac 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -96,7 +96,11 @@ export class Config { if (!this.rawConfig) { throw new Error("Config not loaded. Call load() first."); } - license.setServerSecret(this.rawConfig.server.secret); + if (this.rawConfig.hybrid) { + // LETS NOT WORRY ABOUT THE SERVER SECRET WHEN HYBRID + return; + } + license.setServerSecret(this.rawConfig.server.secret!); await this.checkKeyStatus(); } diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index fa05aebd..23098ac9 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -16,21 +16,28 @@ export const configSchema = z dashboard_url: z .string() .url() - .optional() .pipe(z.string().url()) - .transform((url) => url.toLowerCase()), + .transform((url) => url.toLowerCase()) + .optional(), log_level: z .enum(["debug", "info", "warn", "error"]) .optional() .default("info"), save_logs: z.boolean().optional().default(false), log_failed_attempts: z.boolean().optional().default(false), - telmetry: z + telemetry: z .object({ anonymous_usage: z.boolean().optional().default(true) }) .optional() .default({}) + }).optional().default({ + log_level: "info", + save_logs: false, + log_failed_attempts: false, + telemetry: { + anonymous_usage: true + } }), hybrid: z .object({ @@ -122,9 +129,25 @@ export const configSchema = z trust_proxy: z.number().int().gte(0).optional().default(1), secret: z .string() - .optional() .transform(getEnvOrYaml("SERVER_SECRET")) .pipe(z.string().min(8)) + .optional() + }).optional().default({ + integration_port: 3003, + external_port: 3000, + internal_port: 3001, + next_port: 3002, + internal_hostname: "pangolin", + session_cookie_name: "p_session_token", + resource_access_token_param: "p_token", + resource_access_token_headers: { + id: "P-Access-Token-Id", + token: "P-Access-Token" + }, + resource_session_request_param: "resource_session_request_param", + dashboard_session_length_hours: 720, + resource_session_length_hours: 720, + trust_proxy: 1 }), postgres: z .object({ @@ -282,6 +305,10 @@ export const configSchema = z if (data.flags?.disable_config_managed_domains) { return true; } + // If hybrid is defined, domains are not required + if (data.hybrid) { + return true; + } if (keys.length === 0) { return false; } @@ -290,6 +317,32 @@ export const configSchema = z { message: "At least one domain must be defined" } + ) + .refine( + (data) => { + // If hybrid is defined, server secret is not required + if (data.hybrid) { + return true; + } + // If hybrid is not defined, server secret must be defined + return data.server?.secret !== undefined && data.server.secret.length > 0; + }, + { + message: "Server secret must be defined" + } + ) + .refine( + (data) => { + // If hybrid is defined, dashboard_url is not required + if (data.hybrid) { + return true; + } + // If hybrid is not defined, dashboard_url must be defined + return data.app.dashboard_url !== undefined && data.app.dashboard_url.length > 0; + }, + { + message: "Dashboard URL must be defined" + } ); export function readConfigFile() { diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index ed3a8e73..cd000767 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -16,7 +16,7 @@ class TelemetryClient { private intervalId: NodeJS.Timeout | null = null; constructor() { - const enabled = config.getRawConfig().app.telmetry.anonymous_usage; + const enabled = config.getRawConfig().app.telemetry.anonymous_usage; this.enabled = enabled; const dev = process.env.ENVIRONMENT !== "prod"; diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index dad3c692..6b014986 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -36,16 +36,16 @@ import { verifyTotpCode } from "@server/auth/totp"; // The RP ID is the domain name of your application const rpID = (() => { - const url = new URL(config.getRawConfig().app.dashboard_url); + const url = config.getRawConfig().app.dashboard_url ? new URL(config.getRawConfig().app.dashboard_url!) : undefined; // For localhost, we must use 'localhost' without port - if (url.hostname === 'localhost') { + if (url?.hostname === 'localhost' || !url) { return 'localhost'; } return url.hostname; })(); const rpName = "Pangolin"; -const origin = config.getRawConfig().app.dashboard_url; +const origin = config.getRawConfig().app.dashboard_url || "localhost"; // Database-based challenge storage (replaces in-memory storage) // Challenges are stored in the webauthnChallenge table with automatic expiration diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index eccee475..b8c00192 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -8,7 +8,7 @@ export async function copyInConfig() { const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; - if (!config.getRawConfig().flags?.disable_config_managed_domains) { + if (!config.getRawConfig().flags?.disable_config_managed_domains && config.getRawConfig().domains) { await copyInDomains(); } diff --git a/server/setup/ensureSetupToken.ts b/server/setup/ensureSetupToken.ts index 1734b5e6..49608218 100644 --- a/server/setup/ensureSetupToken.ts +++ b/server/setup/ensureSetupToken.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; import moment from "moment"; import logger from "@server/logger"; +import config from "@server/lib/config"; const random: RandomReader = { read(bytes: Uint8Array): void { @@ -22,6 +23,11 @@ function generateId(length: number): string { } export async function ensureSetupToken() { + if (config.isHybridMode()) { + // LETS NOT WORRY ABOUT THE SERVER SECRET WHEN HYBRID + return; + } + try { // Check if a server admin already exists const [existingAdmin] = await db