Add setup script

This commit is contained in:
Owen Schwartz 2024-12-26 18:05:23 -05:00
parent 34e3e7c819
commit 37790c850a
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
11 changed files with 556 additions and 1 deletions

1
.gitignore vendored
View file

@ -26,6 +26,5 @@ migrations
package-lock.json package-lock.json
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
config/ config/
config.yml
dist dist
.dist .dist

View file

@ -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

1
install/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
installer

8
install/Makefile Normal file
View file

@ -0,0 +1,8 @@
all: build
build:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o installer
clean:
rm installer

48
install/fs/config.yml Normal file
View file

@ -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}}

View file

@ -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

View file

@ -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

View file

@ -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

3
install/go.mod Normal file
View file

@ -0,0 +1,3 @@
module installer
go 1.23.0

0
install/go.sum Normal file
View file

296
install/main.go Normal file
View file

@ -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
}