mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-28 05:44:01 +02:00
Move docker podman question and add hybird question
Allow empty config Continue to adjust config for hybrid
This commit is contained in:
parent
2907f22200
commit
907dab7d05
9 changed files with 207 additions and 116 deletions
173
install/main.go
173
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,6 +377,9 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
|
||||
// Basic configuration
|
||||
fmt.Println("\n=== Basic Configuration ===")
|
||||
config.HybridMode = readBoolNoDefault(reader, "Do you want to use hybrid mode?")
|
||||
|
||||
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
|
||||
|
@ -371,9 +389,12 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
}
|
||||
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)
|
||||
|
||||
if !config.HybridMode {
|
||||
// Email configuration
|
||||
fmt.Println("\n=== Email Configuration ===")
|
||||
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
|
||||
|
@ -386,6 +407,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
config.EmailNoReply = readString(reader, "Enter no-reply email address", "")
|
||||
}
|
||||
|
||||
|
||||
// Validate required fields
|
||||
if config.BaseDomain == "" {
|
||||
fmt.Println("Error: Domain name is required")
|
||||
|
@ -399,6 +421,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
|||
fmt.Println("Error: Let's Encrypt email is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue