Move docker podman question and add hybird question

Allow empty config

Continue to adjust config for hybrid
This commit is contained in:
Owen 2025-08-20 10:26:32 -07:00
parent 2907f22200
commit 907dab7d05
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
9 changed files with 207 additions and 116 deletions

View file

@ -52,6 +52,7 @@ type Config struct {
TraefikBouncerKey string TraefikBouncerKey string
DoCrowdsecInstall bool DoCrowdsecInstall bool
Secret string Secret string
HybridMode bool
} }
type SupportedContainer string type SupportedContainer string
@ -70,9 +71,6 @@ func main() {
fmt.Println("") fmt.Println("")
fmt.Println("Please make sure you have the following prerequisites:") 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("- 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("")
fmt.Println("Lets get started!") fmt.Println("Lets get started!")
fmt.Println("") fmt.Println("")
@ -89,71 +87,8 @@ func main() {
} }
reader := bufio.NewReader(os.Stdin) 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 var config Config
config.InstallationContainerType = chosenContainer
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
@ -170,7 +105,9 @@ func main() {
moveFile("config/docker-compose.yml", "docker-compose.yml") 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) { if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker() installDocker()
// try to start docker service but ignore errors // try to start docker service but ignore errors
@ -199,15 +136,15 @@ func main() {
fmt.Println("\n=== Starting installation ===") fmt.Println("\n=== Starting installation ===")
if (isDockerInstalled() && chosenContainer == Docker) || if (isDockerInstalled() && config.InstallationContainerType == Docker) ||
(isPodmanInstalled() && chosenContainer == Podman) { (isPodmanInstalled() && config.InstallationContainerType == Podman) {
if readBool(reader, "Would you like to install and start the containers?", true) { 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) fmt.Println("Error: ", err)
return return
} }
if err := startContainers(chosenContainer); err != nil { if err := startContainers(config.InstallationContainerType); err != nil {
fmt.Println("Error: ", err) fmt.Println("Error: ", err)
return return
} }
@ -288,22 +225,89 @@ func main() {
// Check if containers were started during this installation // Check if containers were started during this installation
containersStarted := false containersStarted := false
if (isDockerInstalled() && chosenContainer == Docker) || if (isDockerInstalled() && config.InstallationContainerType == Docker) ||
(isPodmanInstalled() && chosenContainer == Podman) { (isPodmanInstalled() && config.InstallationContainerType == Podman) {
// Try to fetch and display the token if containers are running // Try to fetch and display the token if containers are running
containersStarted = true containersStarted = true
printSetupToken(chosenContainer, config.DashboardDomain) printSetupToken(config.InstallationContainerType, config.DashboardDomain)
} }
// If containers weren't started or token wasn't found, show instructions // If containers weren't started or token wasn't found, show instructions
if !containersStarted { if !containersStarted {
showSetupTokenInstructions(chosenContainer, config.DashboardDomain) showSetupTokenInstructions(config.InstallationContainerType, config.DashboardDomain)
} }
fmt.Println("Installation complete!") fmt.Println("Installation complete!")
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) 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 { func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
if defaultValue != "" { if defaultValue != "" {
fmt.Printf("%s (default: %s): ", prompt, defaultValue) fmt.Printf("%s (default: %s): ", prompt, defaultValue)
@ -318,6 +322,12 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
return input 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 { func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) { if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ") fmt.Print(prompt + ": ")
@ -347,6 +357,11 @@ func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
return strings.ToLower(input) == "yes" 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 { func readInt(reader *bufio.Reader, prompt string, defaultValue int) int {
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue)) input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
if input == "" { if input == "" {
@ -362,42 +377,50 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration // Basic configuration
fmt.Println("\n=== 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 if !config.HybridMode {
defaultDashboardDomain := "" config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
if config.BaseDomain != "" {
defaultDashboardDomain = "pangolin." + config.BaseDomain // 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.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
// Email configuration if !config.HybridMode {
fmt.Println("\n=== Email Configuration ===") // Email configuration
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) fmt.Println("\n=== Email Configuration ===")
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
if config.EnableEmail { if config.EnableEmail {
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
config.EmailNoReply = readString(reader, "Enter no-reply email address", "") config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
} }
// Validate required fields
if config.BaseDomain == "" { // Validate required fields
fmt.Println("Error: Domain name is required") if config.BaseDomain == "" {
os.Exit(1) fmt.Println("Error: Domain name is required")
} os.Exit(1)
if config.DashboardDomain == "" { }
fmt.Println("Error: Dashboard Domain name is required") if config.DashboardDomain == "" {
os.Exit(1) fmt.Println("Error: Dashboard Domain name is required")
} os.Exit(1)
if config.LetsEncryptEmail == "" { }
fmt.Println("Error: Let's Encrypt email is required") if config.LetsEncryptEmail == "" {
os.Exit(1) fmt.Println("Error: Let's Encrypt email is required")
os.Exit(1)
}
} }
return config return config

View file

@ -24,8 +24,8 @@ export const SESSION_COOKIE_EXPIRES =
60 * 60 *
60 * 60 *
config.getRawConfig().server.dashboard_session_length_hours; config.getRawConfig().server.dashboard_session_length_hours;
export const COOKIE_DOMAIN = export const COOKIE_DOMAIN = config.getRawConfig().app.dashboard_url ?
"." + new URL(config.getRawConfig().app.dashboard_url).hostname; "." + new URL(config.getRawConfig().app.dashboard_url!).hostname : undefined;
export function generateSessionToken(): string { export function generateSessionToken(): string {
const bytes = new Uint8Array(20); const bytes = new Uint8Array(20);

View file

@ -6,6 +6,11 @@ import logger from "@server/logger";
import SMTPTransport from "nodemailer/lib/smtp-transport"; import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() { function createEmailClient() {
if (config.isHybridMode()) {
// LETS NOT WORRY ABOUT EMAILS IN HYBRID
return;
}
const emailConfig = config.getRawConfig().email; const emailConfig = config.getRawConfig().email;
if (!emailConfig) { if (!emailConfig) {
logger.warn( logger.warn(

View file

@ -96,7 +96,11 @@ export class Config {
if (!this.rawConfig) { if (!this.rawConfig) {
throw new Error("Config not loaded. Call load() first."); 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(); await this.checkKeyStatus();
} }

View file

@ -16,21 +16,28 @@ export const configSchema = z
dashboard_url: z dashboard_url: z
.string() .string()
.url() .url()
.optional()
.pipe(z.string().url()) .pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase())
.optional(),
log_level: z log_level: z
.enum(["debug", "info", "warn", "error"]) .enum(["debug", "info", "warn", "error"])
.optional() .optional()
.default("info"), .default("info"),
save_logs: z.boolean().optional().default(false), save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false), log_failed_attempts: z.boolean().optional().default(false),
telmetry: z telemetry: z
.object({ .object({
anonymous_usage: z.boolean().optional().default(true) anonymous_usage: z.boolean().optional().default(true)
}) })
.optional() .optional()
.default({}) .default({})
}).optional().default({
log_level: "info",
save_logs: false,
log_failed_attempts: false,
telemetry: {
anonymous_usage: true
}
}), }),
hybrid: z hybrid: z
.object({ .object({
@ -122,9 +129,25 @@ export const configSchema = z
trust_proxy: z.number().int().gte(0).optional().default(1), trust_proxy: z.number().int().gte(0).optional().default(1),
secret: z secret: z
.string() .string()
.optional()
.transform(getEnvOrYaml("SERVER_SECRET")) .transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8)) .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 postgres: z
.object({ .object({
@ -282,6 +305,10 @@ export const configSchema = z
if (data.flags?.disable_config_managed_domains) { if (data.flags?.disable_config_managed_domains) {
return true; return true;
} }
// If hybrid is defined, domains are not required
if (data.hybrid) {
return true;
}
if (keys.length === 0) { if (keys.length === 0) {
return false; return false;
} }
@ -290,6 +317,32 @@ export const configSchema = z
{ {
message: "At least one domain must be defined" 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() { export function readConfigFile() {

View file

@ -16,7 +16,7 @@ class TelemetryClient {
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
constructor() { constructor() {
const enabled = config.getRawConfig().app.telmetry.anonymous_usage; const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
this.enabled = enabled; this.enabled = enabled;
const dev = process.env.ENVIRONMENT !== "prod"; const dev = process.env.ENVIRONMENT !== "prod";

View file

@ -36,16 +36,16 @@ import { verifyTotpCode } from "@server/auth/totp";
// The RP ID is the domain name of your application // The RP ID is the domain name of your application
const rpID = (() => { 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 // For localhost, we must use 'localhost' without port
if (url.hostname === 'localhost') { if (url?.hostname === 'localhost' || !url) {
return 'localhost'; return 'localhost';
} }
return url.hostname; return url.hostname;
})(); })();
const rpName = "Pangolin"; 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) // Database-based challenge storage (replaces in-memory storage)
// Challenges are stored in the webauthnChallenge table with automatic expiration // Challenges are stored in the webauthnChallenge table with automatic expiration

View file

@ -8,7 +8,7 @@ export async function copyInConfig() {
const endpoint = config.getRawConfig().gerbil.base_endpoint; const endpoint = config.getRawConfig().gerbil.base_endpoint;
const listenPort = config.getRawConfig().gerbil.start_port; 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(); await copyInDomains();
} }

View file

@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; import { generateRandomString, RandomReader } from "@oslojs/crypto/random";
import moment from "moment"; import moment from "moment";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config";
const random: RandomReader = { const random: RandomReader = {
read(bytes: Uint8Array): void { read(bytes: Uint8Array): void {
@ -22,6 +23,11 @@ function generateId(length: number): string {
} }
export async function ensureSetupToken() { export async function ensureSetupToken() {
if (config.isHybridMode()) {
// LETS NOT WORRY ABOUT THE SERVER SECRET WHEN HYBRID
return;
}
try { try {
// Check if a server admin already exists // Check if a server admin already exists
const [existingAdmin] = await db const [existingAdmin] = await db