diff --git a/.gitignore b/.gitignore index 8ae65513..a3549579 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,5 @@ migrations package-lock.json tsconfig.tsbuildinfo config/ -config.yml dist .dist \ No newline at end of file diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 00000000..d22fed3d --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,54 @@ +version: "3.7" + +services: + pangolin: + image: fossorial/pangolin + container_name: pangolin + restart: unless-stopped + ports: + - 3001:3001 + - 3000:3000 + volumes: + - ./config:/app/config + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] + interval: "3s" + timeout: "3s" + retries: 5 + + gerbil: + image: fossorial/gerbil + container_name: gerbil + restart: unless-stopped + depends_on: + pangolin: + condition: service_healthy + command: + - --reachableAt=http://gerbil:3003 + - --generateAndSaveKeyTo=/var/config/key + - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config + - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth + volumes: + - ./config/:/var/config + cap_add: + - NET_ADMIN + - SYS_MODULE + ports: + - 51820:51820/udp + - 8080:8080 # Port for traefik because of the network_mode + - 443:443 # Port for traefik because of the network_mode + - 80:80 # Port for traefik because of the network_mode + + traefik: + image: traefik:v3.1 + container_name: traefik + restart: unless-stopped + network_mode: service:gerbil # Ports appear on the gerbil service + depends_on: + pangolin: + condition: service_healthy + command: + - --configFile=/etc/traefik/traefik_config.yml + volumes: + - ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration + - ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates diff --git a/install/.gitignore b/install/.gitignore new file mode 100644 index 00000000..86789cb3 --- /dev/null +++ b/install/.gitignore @@ -0,0 +1 @@ +installer \ No newline at end of file diff --git a/install/Makefile b/install/Makefile new file mode 100644 index 00000000..647bff8b --- /dev/null +++ b/install/Makefile @@ -0,0 +1,8 @@ + +all: build + +build: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer + +clean: + rm installer \ No newline at end of file diff --git a/install/fs/config.yml b/install/fs/config.yml new file mode 100644 index 00000000..1ebd11a4 --- /dev/null +++ b/install/fs/config.yml @@ -0,0 +1,48 @@ +app: + base_url: https://{{.Domain}} + log_level: info + save_logs: false + +server: + external_port: 3000 + internal_port: 3001 + next_port: 3002 + internal_hostname: pangolin + secure_cookies: false + session_cookie_name: session + resource_session_cookie_name: resource_session + +traefik: + cert_resolver: letsencrypt + http_entrypoint: web + https_entrypoint: websecure + prefer_wildcard_cert: false + +gerbil: + start_port: 51820 + base_endpoint: {{.Domain}} + use_subdomain: false + block_size: 16 + subnet_group: 10.0.0.0/8 + +rate_limits: + global: + window_minutes: 1 + max_requests: 100 +{{if .EnableEmail}} +email: + smtp_host: {{.EmailSMTPHost}} + smtp_port: {{.EmailSMTPPort}} + smtp_user: {{.EmailSMTPUser}} + smtp_pass: {{.EmailSMTPPass}} + no_reply: {{.EmailNoReply}} +{{end}} +users: + server_admin: + email: {{.AdminUserEmail}} + password: {{.AdminUserPassword}} + +flags: + require_email_verification: {{.EnableEmail}} + disable_signup_without_invite: {{.DisableSignupWithoutInvite}} + disable_user_create_org: {{.DisableUserCreateOrg}} diff --git a/install/fs/docker-compose.yml b/install/fs/docker-compose.yml new file mode 100644 index 00000000..6015beda --- /dev/null +++ b/install/fs/docker-compose.yml @@ -0,0 +1,51 @@ +services: + pangolin: + image: fossorial/pangolin + container_name: pangolin + restart: unless-stopped + ports: + - 3001:3001 + - 3000:3000 + volumes: + - ./config:/app/config + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] + interval: "3s" + timeout: "3s" + retries: 5 + + gerbil: + image: fossorial/gerbil + container_name: gerbil + restart: unless-stopped + depends_on: + pangolin: + condition: service_healthy + command: + - --reachableAt=http://gerbil:3003 + - --generateAndSaveKeyTo=/var/config/key + - --remoteConfig=http://pangolin:3001/api/v1/gerbil/get-config + - --reportBandwidthTo=http://pangolin:3001/api/v1/gerbil/receive-bandwidth + volumes: + - ./config/:/var/config + cap_add: + - NET_ADMIN + - SYS_MODULE + ports: + - 51820:51820/udp + - 443:443 # Port for traefik because of the network_mode + - 80:80 # Port for traefik because of the network_mode + + traefik: + image: traefik:v3.1 + container_name: traefik + restart: unless-stopped + network_mode: service:gerbil # Ports appear on the gerbil service + depends_on: + pangolin: + condition: service_healthy + command: + - --configFile=/etc/traefik/traefik_config.yml + volumes: + - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration + - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml new file mode 100644 index 00000000..cb21242d --- /dev/null +++ b/install/fs/traefik/dynamic_config.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(`{{.Domain}}`)" + service: next-service + entryPoints: + - web + middlewares: + - redirect-to-https + + # Next.js router (handles everything except API and WebSocket paths) + next-router: + rule: "Host(`{{.Domain}}`) && !PathPrefix(`/api/v1`)" + service: next-service + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + # API router (handles /api/v1 paths) + api-router: + rule: "Host(`{{.Domain}}`) && PathPrefix(`/api/v1`)" + service: api-service + entryPoints: + - websecure + tls: + certResolver: letsencrypt + + # WebSocket router + ws-router: + rule: "Host(`{{.Domain}}`)" + 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/install/fs/traefik/traefik_config.yml b/install/fs/traefik/traefik_config.yml new file mode 100644 index 00000000..c83cc8c4 --- /dev/null +++ b/install/fs/traefik/traefik_config.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.1" + +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/go.mod b/install/go.mod new file mode 100644 index 00000000..3de61fa9 --- /dev/null +++ b/install/go.mod @@ -0,0 +1,3 @@ +module installer + +go 1.23.0 \ No newline at end of file diff --git a/install/go.sum b/install/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/install/main.go b/install/main.go new file mode 100644 index 00000000..b2d0d790 --- /dev/null +++ b/install/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bufio" + "embed" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "text/template" +) + +//go:embed fs/* +var configFiles embed.FS + +type Config struct { + Domain string `yaml:"domain"` + LetsEncryptEmail string `yaml:"letsEncryptEmail"` + AdminUserEmail string `yaml:"adminUserEmail"` + AdminUserPassword string `yaml:"adminUserPassword"` + DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"` + DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"` + EnableEmail bool `yaml:"enableEmail"` + EmailSMTPHost string `yaml:"emailSMTPHost"` + EmailSMTPPort int `yaml:"emailSMTPPort"` + EmailSMTPUser string `yaml:"emailSMTPUser"` + EmailSMTPPass string `yaml:"emailSMTPPass"` + EmailNoReply string `yaml:"emailNoReply"` +} + +func main() { + reader := bufio.NewReader(os.Stdin) + + config := collectUserInput(reader) + createConfigFiles(config) + + if !isDockerInstalled() && runtime.GOOS == "linux" { + if shouldInstallDocker() { + // ask user if they want to install docker + if readBool(reader, "Would you like to install Docker?", true) { + installDocker() + } + } + } + + if isDockerInstalled() { + if readBool(reader, "Would you like to install and start the containers?", true) { + pullAndStartContainers() + } + } + + fmt.Println("Installation complete!") +} + +func readString(reader *bufio.Reader, prompt string, defaultValue string) string { + if defaultValue != "" { + fmt.Printf("%s (default: %s): ", prompt, defaultValue) + } else { + fmt.Print(prompt + ": ") + } + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) + if input == "" { + return defaultValue + } + return input +} + +func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { + defaultStr := "no" + if defaultValue { + defaultStr = "yes" + } + input := readString(reader, prompt+" (yes/no)", defaultStr) + 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 == "" { + return defaultValue + } + value := defaultValue + fmt.Sscanf(input, "%d", &value) + return value +} + +func collectUserInput(reader *bufio.Reader) Config { + config := Config{} + + // Basic configuration + fmt.Println("\n=== Basic Configuration ===") + config.Domain = readString(reader, "Enter your domain name", "") + config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") + + // Admin user configuration + fmt.Println("\n=== Admin User Configuration ===") + config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.Domain) + config.AdminUserPassword = readString(reader, "Enter admin user password", "") + + // Security settings + fmt.Println("\n=== Security Settings ===") + config.DisableSignupWithoutInvite = readBool(reader, "Disable signup without invite", true) + config.DisableUserCreateOrg = readBool(reader, "Disable users from creating organizations", false) + + // Email configuration + fmt.Println("\n=== Email Configuration ===") + config.EnableEmail = readBool(reader, "Enable email functionality", 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: ", "") + config.EmailNoReply = readString(reader, "Enter no-reply email address: ", "") + } + + // Validate required fields + if config.Domain == "" { + fmt.Println("Error: Domain name is required") + os.Exit(1) + } + if config.LetsEncryptEmail == "" { + fmt.Println("Error: Let's Encrypt email is required") + os.Exit(1) + } + if config.AdminUserEmail == "" || config.AdminUserPassword == "" { + fmt.Println("Error: Admin user email and password are required") + os.Exit(1) + } + + return config +} + +func createConfigFiles(config Config) error { + os.MkdirAll("config", 0755) + os.MkdirAll("config/letsencrypt", 0755) + os.MkdirAll("config/db", 0755) + os.MkdirAll("config/logs", 0755) + + // Walk through all embedded files + err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root fs directory itself + if path == "fs" { + return nil + } + + // Get the relative path by removing the "fs/" prefix + relPath := strings.TrimPrefix(path, "fs/") + + // Create the full output path under "config/" + outPath := filepath.Join("config", relPath) + + if d.IsDir() { + // Create directory + if err := os.MkdirAll(outPath, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", outPath, err) + } + return nil + } + + // Read the template file + content, err := configFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %v", path, err) + } + + // Parse template + tmpl, err := template.New(d.Name()).Parse(string(content)) + if err != nil { + return fmt.Errorf("failed to parse template %s: %v", path, err) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %v", outPath, err) + } + + // Create output file + outFile, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("failed to create %s: %v", outPath, err) + } + defer outFile.Close() + + // Execute template + if err := tmpl.Execute(outFile, config); err != nil { + return fmt.Errorf("failed to execute template %s: %v", path, err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("error walking config files: %v", err) + } + + // move the docker-compose.yml file to the root directory + os.Rename("config/docker-compose.yml", "docker-compose.yml") + + return nil +} + +func shouldInstallDocker() bool { + reader := bufio.NewReader(os.Stdin) + fmt.Print("Would you like to install Docker? (yes/no): ") + response, _ := reader.ReadString('\n') + return strings.ToLower(strings.TrimSpace(response)) == "yes" +} + +func installDocker() error { + // Detect Linux distribution + cmd := exec.Command("cat", "/etc/os-release") + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to detect Linux distribution: %v", err) + } + + osRelease := string(output) + var installCmd *exec.Cmd + + switch { + case strings.Contains(osRelease, "ID=ubuntu") || strings.Contains(osRelease, "ID=debian"): + installCmd = exec.Command("bash", "-c", ` + apt-get update && + apt-get install -y apt-transport-https ca-certificates curl software-properties-common && + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && + apt-get update && + apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + `) + case strings.Contains(osRelease, "ID=fedora"): + installCmd = exec.Command("bash", "-c", ` + dnf -y install dnf-plugins-core && + dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && + dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + `) + default: + return fmt.Errorf("unsupported Linux distribution") + } + + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + return installCmd.Run() +} + +func isDockerInstalled() bool { + cmd := exec.Command("docker", "--version") + if err := cmd.Run(); err != nil { + return false + } + return true +} + +func pullAndStartContainers() error { + containers := []string{ + "traefik:v3.1", + "fossorial/pangolin:latest", + "fossorial/gerbil:latest", + } + + for _, container := range containers { + fmt.Printf("Pulling %s...\n", container) + cmd := exec.Command("docker", "pull", container) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to pull %s: %v", container, err) + } + } + + fmt.Println("Starting containers...") + + // First try docker compose (new style) + cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + + // If docker compose fails, try docker-compose (legacy style) + if err != nil { + cmd = exec.Command("docker-compose", "-f", "docker-compose.yml", "up", "-d") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + } + + return err +}