diff --git a/install/config.go b/install/config.go new file mode 100644 index 00000000..3be62601 --- /dev/null +++ b/install/config.go @@ -0,0 +1,353 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" + + "gopkg.in/yaml.v3" +) + +// TraefikConfig represents the structure of the main Traefik configuration +type TraefikConfig struct { + Experimental struct { + Plugins struct { + Badger struct { + Version string `yaml:"version"` + } `yaml:"badger"` + } `yaml:"plugins"` + } `yaml:"experimental"` + CertificatesResolvers struct { + LetsEncrypt struct { + Acme struct { + Email string `yaml:"email"` + } `yaml:"acme"` + } `yaml:"letsencrypt"` + } `yaml:"certificatesResolvers"` +} + +// DynamicConfig represents the structure of the dynamic configuration +type DynamicConfig struct { + HTTP struct { + Routers map[string]struct { + Rule string `yaml:"rule"` + } `yaml:"routers"` + } `yaml:"http"` +} + +// ConfigValues holds the extracted configuration values +type ConfigValues struct { + DashboardDomain string + LetsEncryptEmail string + BadgerVersion string +} + +// ReadTraefikConfig reads and extracts values from Traefik configuration files +func ReadTraefikConfig(mainConfigPath, dynamicConfigPath string) (*ConfigValues, error) { + // Read main config file + mainConfigData, err := os.ReadFile(mainConfigPath) + if err != nil { + return nil, fmt.Errorf("error reading main config file: %w", err) + } + + var mainConfig TraefikConfig + if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil { + return nil, fmt.Errorf("error parsing main config file: %w", err) + } + + // Read dynamic config file + dynamicConfigData, err := os.ReadFile(dynamicConfigPath) + if err != nil { + return nil, fmt.Errorf("error reading dynamic config file: %w", err) + } + + var dynamicConfig DynamicConfig + if err := yaml.Unmarshal(dynamicConfigData, &dynamicConfig); err != nil { + return nil, fmt.Errorf("error parsing dynamic config file: %w", err) + } + + // Extract values + values := &ConfigValues{ + BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version, + LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email, + } + + // Extract DashboardDomain from router rules + // Look for it in the main router rules + for _, router := range dynamicConfig.HTTP.Routers { + if router.Rule != "" { + // Extract domain from Host(`mydomain.com`) + if domain := extractDomainFromRule(router.Rule); domain != "" { + values.DashboardDomain = domain + break + } + } + } + + return values, nil +} + +// extractDomainFromRule extracts the domain from a router rule +func extractDomainFromRule(rule string) string { + // Look for the Host(`mydomain.com`) pattern + if start := findPattern(rule, "Host(`"); start != -1 { + end := findPattern(rule[start:], "`)") + if end != -1 { + return rule[start+6 : start+end] + } + } + return "" +} + +// findPattern finds the start of a pattern in a string +func findPattern(s, pattern string) int { + return bytes.Index([]byte(s), []byte(pattern)) +} + +func copyDockerService(sourceFile, destFile, serviceName string) error { + // Read source file + sourceData, err := os.ReadFile(sourceFile) + if err != nil { + return fmt.Errorf("error reading source file: %w", err) + } + + // Read destination file + destData, err := os.ReadFile(destFile) + if err != nil { + return fmt.Errorf("error reading destination file: %w", err) + } + + // Parse source Docker Compose YAML + var sourceCompose map[string]interface{} + if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil { + return fmt.Errorf("error parsing source Docker Compose file: %w", err) + } + + // Parse destination Docker Compose YAML + var destCompose map[string]interface{} + if err := yaml.Unmarshal(destData, &destCompose); err != nil { + return fmt.Errorf("error parsing destination Docker Compose file: %w", err) + } + + // Get services section from source + sourceServices, ok := sourceCompose["services"].(map[string]interface{}) + if !ok { + return fmt.Errorf("services section not found in source file or has invalid format") + } + + // Get the specific service configuration + serviceConfig, ok := sourceServices[serviceName] + if !ok { + return fmt.Errorf("service '%s' not found in source file", serviceName) + } + + // Get or create services section in destination + destServices, ok := destCompose["services"].(map[string]interface{}) + if !ok { + // If services section doesn't exist, create it + destServices = make(map[string]interface{}) + destCompose["services"] = destServices + } + + // Update service in destination + destServices[serviceName] = serviceConfig + + // Marshal updated destination YAML + // Use yaml.v3 encoder to preserve formatting and comments + // updatedData, err := yaml.Marshal(destCompose) + updatedData, err := MarshalYAMLWithIndent(destCompose, 2) + if err != nil { + return fmt.Errorf("error marshaling updated Docker Compose file: %w", err) + } + + // Write updated YAML back to destination file + if err := os.WriteFile(destFile, updatedData, 0644); err != nil { + return fmt.Errorf("error writing to destination file: %w", err) + } + + return nil +} + +func backupConfig() error { + // Backup docker-compose.yml + if _, err := os.Stat("docker-compose.yml"); err == nil { + if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil { + return fmt.Errorf("failed to backup docker-compose.yml: %v", err) + } + } + + // Backup config directory + if _, err := os.Stat("config"); err == nil { + cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to backup config directory: %v", err) + } + } + + return nil +} + +func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { + buffer := new(bytes.Buffer) + encoder := yaml.NewEncoder(buffer) + encoder.SetIndent(indent) + + err := encoder.Encode(data) + if err != nil { + return nil, err + } + + defer encoder.Close() + return buffer.Bytes(), nil +} + +func replaceInFile(filepath, oldStr, newStr string) error { + // Read the file content + content, err := os.ReadFile(filepath) + if err != nil { + return fmt.Errorf("error reading file: %v", err) + } + + // Replace the string + newContent := strings.Replace(string(content), oldStr, newStr, -1) + + // Write the modified content back to the file + err = os.WriteFile(filepath, []byte(newContent), 0644) + if err != nil { + return fmt.Errorf("error writing file: %v", err) + } + + return nil +} + +func CheckAndAddTraefikLogVolume(composePath string) error { + // Read the docker-compose.yml file + data, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("error reading compose file: %w", err) + } + + // Parse YAML into a generic map + var compose map[string]interface{} + if err := yaml.Unmarshal(data, &compose); err != nil { + return fmt.Errorf("error parsing compose file: %w", err) + } + + // Get services section + services, ok := compose["services"].(map[string]interface{}) + if !ok { + return fmt.Errorf("services section not found or invalid") + } + + // Get traefik service + traefik, ok := services["traefik"].(map[string]interface{}) + if !ok { + return fmt.Errorf("traefik service not found or invalid") + } + + // Check volumes + logVolume := "./config/traefik/logs:/var/log/traefik" + var volumes []interface{} + + if existingVolumes, ok := traefik["volumes"].([]interface{}); ok { + // Check if volume already exists + for _, v := range existingVolumes { + if v.(string) == logVolume { + fmt.Println("Traefik log volume is already configured") + return nil + } + } + volumes = existingVolumes + } + + // Add new volume + volumes = append(volumes, logVolume) + traefik["volumes"] = volumes + + // Write updated config back to file + newData, err := MarshalYAMLWithIndent(compose, 2) + if err != nil { + return fmt.Errorf("error marshaling updated compose file: %w", err) + } + + if err := os.WriteFile(composePath, newData, 0644); err != nil { + return fmt.Errorf("error writing updated compose file: %w", err) + } + + fmt.Println("Added traefik log volume and created logs directory") + return nil +} + +// MergeYAML merges two YAML files, where the contents of the second file +// are merged into the first file. In case of conflicts, values from the +// second file take precedence. +func MergeYAML(baseFile, overlayFile string) error { + // Read the base YAML file + baseContent, err := os.ReadFile(baseFile) + if err != nil { + return fmt.Errorf("error reading base file: %v", err) + } + + // Read the overlay YAML file + overlayContent, err := os.ReadFile(overlayFile) + if err != nil { + return fmt.Errorf("error reading overlay file: %v", err) + } + + // Parse base YAML into a map + var baseMap map[string]interface{} + if err := yaml.Unmarshal(baseContent, &baseMap); err != nil { + return fmt.Errorf("error parsing base YAML: %v", err) + } + + // Parse overlay YAML into a map + var overlayMap map[string]interface{} + if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil { + return fmt.Errorf("error parsing overlay YAML: %v", err) + } + + // Merge the overlay into the base + merged := mergeMap(baseMap, overlayMap) + + // Marshal the merged result back to YAML + mergedContent, err := MarshalYAMLWithIndent(merged, 2) + if err != nil { + return fmt.Errorf("error marshaling merged YAML: %v", err) + } + + // Write the merged content back to the base file + if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil { + return fmt.Errorf("error writing merged YAML: %v", err) + } + + return nil +} + +// mergeMap recursively merges two maps +func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy all key-values from base map + for k, v := range base { + result[k] = v + } + + // Merge overlay values + for k, v := range overlay { + // If both maps have the same key and both values are maps, merge recursively + if baseVal, ok := base[k]; ok { + if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap { + if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap { + result[k] = mergeMap(baseMap, overlayMap) + continue + } + } + } + // Otherwise, overlay value takes precedence + result[k] = v + } + + return result +} diff --git a/install/fs/config.yml b/install/config/config.yml similarity index 100% rename from install/fs/config.yml rename to install/config/config.yml diff --git a/install/config/crowdsec/acquis.yaml b/install/config/crowdsec/acquis.yaml new file mode 100644 index 00000000..74d8fd1c --- /dev/null +++ b/install/config/crowdsec/acquis.yaml @@ -0,0 +1,18 @@ +filenames: + - /var/log/auth.log + - /var/log/syslog +labels: + type: syslog +--- +poll_without_inotify: false +filenames: + - /var/log/traefik/*.log +labels: + type: traefik +--- +listen_addr: 0.0.0.0:7422 +appsec_config: crowdsecurity/appsec-default +name: myAppSecComponent +source: appsec +labels: + type: appsec \ No newline at end of file diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml new file mode 100644 index 00000000..982b3335 --- /dev/null +++ b/install/config/crowdsec/docker-compose.yml @@ -0,0 +1,35 @@ +services: + crowdsec: + image: crowdsecurity/crowdsec:latest + container_name: crowdsec + environment: + GID: "1000" + COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules + ENROLL_INSTANCE_NAME: "pangolin-crowdsec" + PARSERS: crowdsecurity/whitelists + ACQUIRE_FILES: "/var/log/traefik/*.log" + ENROLL_TAGS: docker + healthcheck: + test: ["CMD", "cscli", "capi", "status"] + depends_on: + - gerbil # Wait for gerbil to be healthy + labels: + - "traefik.enable=false" # Disable traefik for crowdsec + volumes: + # crowdsec container data + - ./config/crowdsec:/etc/crowdsec # crowdsec config + - ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db + # log bind mounts into crowdsec + - ./config/crowdsec_logs/auth.log:/var/log/auth.log:ro # auth.log + - ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog + - ./config/crowdsec_logs:/var/log # crowdsec logs + - ./config/traefik/logs:/var/log/traefik # traefik logs + ports: + - 9090:9090 # port mapping for local firewall bouncers + - 6060:6060 # metrics endpoint for prometheus + expose: + - 9090 # http api for bouncers + - 6060 # metrics endpoint for prometheus + - 7422 # appsec waf endpoint + restart: unless-stopped + command: -t # Add test config flag to verify configuration \ No newline at end of file diff --git a/install/config/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml new file mode 100644 index 00000000..a3d32dbd --- /dev/null +++ b/install/config/crowdsec/dynamic_config.yml @@ -0,0 +1,108 @@ +http: + middlewares: + redirect-to-https: + redirectScheme: + scheme: https + default-whitelist: # Whitelist middleware for internal IPs + ipWhiteList: # Internal IP addresses + sourceRange: # Internal IP addresses + - "10.0.0.0/8" # Internal IP addresses + - "192.168.0.0/16" # Internal IP addresses + - "172.16.0.0/12" # Internal IP addresses + # Basic security headers + security-headers: + headers: + customResponseHeaders: # Custom response headers + Server: "" # Remove server header + X-Powered-By: "" # Remove powered by header + X-Forwarded-Proto: "https" # Set forwarded proto to https + sslProxyHeaders: # SSL proxy headers + X-Forwarded-Proto: "https" # Set forwarded proto to https + hostsProxyHeaders: # Hosts proxy headers + - "X-Forwarded-Host" # Set forwarded host + contentTypeNosniff: true # Prevent MIME sniffing + customFrameOptionsValue: "SAMEORIGIN" # Set frame options + referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy + forceSTSHeader: true # Force STS header + stsIncludeSubdomains: true # Include subdomains + stsSeconds: 63072000 # STS seconds + stsPreload: true # Preload STS + # CrowdSec configuration with proper IP forwarding + crowdsec: + plugin: + crowdsec: + enabled: true # Enable CrowdSec plugin + logLevel: INFO # Log level + updateIntervalSeconds: 15 # Update interval + updateMaxFailure: 0 # Update max failure + defaultDecisionSeconds: 15 # Default decision seconds + httpTimeoutSeconds: 10 # HTTP timeout + crowdsecMode: live # CrowdSec mode + crowdsecAppsecEnabled: true # Enable AppSec + crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later + crowdsecAppsecFailureBlock: true # Block on failure + crowdsecAppsecUnreachableBlock: true # Block on unreachable + crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later + crowdsecLapiHost: crowdsec:8080 # CrowdSec + crowdsecLapiScheme: http # CrowdSec API scheme + forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs + - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) + clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE) + - "10.0.0.0/8" # Internal LAN IP addresses + - "172.16.0.0/12" # Internal LAN IP addresses + - "192.168.0.0/16" # Internal LAN IP addresses + - "100.89.137.0/20" # Internal LAN IP addresses + + routers: + # HTTP to HTTPS redirect router + main-app-router-redirect: + rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name + service: next-service + entryPoints: + - web + middlewares: + - redirect-to-https + + # Next.js router (handles everything except API and WebSocket paths) + next-router: + rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name + service: next-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + tls: + certResolver: letsencrypt + + # API router (handles /api/v1 paths) + api-router: + rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name + service: api-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + tls: + certResolver: letsencrypt + + # WebSocket router + ws-router: + rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name + service: api-service + entryPoints: + - websecure + middlewares: + - security-headers # Add security headers middleware + 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 \ No newline at end of file diff --git a/install/config/crowdsec/profiles.yaml b/install/config/crowdsec/profiles.yaml new file mode 100644 index 00000000..3796b47f --- /dev/null +++ b/install/config/crowdsec/profiles.yaml @@ -0,0 +1,25 @@ +name: captcha_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" +decisions: + - type: captcha + duration: 4h +on_success: break + +--- +name: default_ip_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" +decisions: + - type: ban + duration: 4h +on_success: break + +--- +name: default_range_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Range" +decisions: + - type: ban + duration: 4h +on_success: break \ No newline at end of file diff --git a/install/config/crowdsec/traefik_config.yml b/install/config/crowdsec/traefik_config.yml new file mode 100644 index 00000000..59356ea7 --- /dev/null +++ b/install/config/crowdsec/traefik_config.yml @@ -0,0 +1,87 @@ +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: "{{.BadgerVersion}}" + crowdsec: # CrowdSec plugin configuration added + moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + version: "v1.3.5" + +log: + level: "INFO" + format: "json" # Log format changed to json for better parsing + +accessLog: # We enable access logs as json + filePath: "/var/log/traefik/access.log" + format: json + filters: + statusCodes: + - "200-299" # Success codes + - "400-499" # Client errors + - "500-599" # Server errors + retryAttempts: true + minDuration: "100ms" # Increased to focus on slower requests + bufferingSize: 100 # Add buffering for better performance + fields: + defaultMode: drop # Start with dropping all fields + names: + ClientAddr: keep # Keep client address for IP tracking + ClientHost: keep # Keep client host for IP tracking + RequestMethod: keep # Keep request method for tracking + RequestPath: keep # Keep request path for tracking + RequestProtocol: keep # Keep request protocol for tracking + DownstreamStatus: keep # Keep downstream status for tracking + DownstreamContentSize: keep # Keep downstream content size for tracking + Duration: keep # Keep request duration for tracking + ServiceName: keep # Keep service name for tracking + StartUTC: keep # Keep start time for tracking + TLSVersion: keep # Keep TLS version for tracking + TLSCipher: keep # Keep TLS cipher for tracking + RetryAttempts: keep # Keep retry attempts for tracking + headers: + defaultMode: drop # Start with dropping all headers + names: + User-Agent: keep # Keep user agent for tracking + X-Real-Ip: keep # Keep real IP for tracking + X-Forwarded-For: keep # Keep forwarded IP for tracking + X-Forwarded-Proto: keep # Keep forwarded protocol for tracking + Content-Type: keep # Keep content type for tracking + Authorization: redact # Redact sensitive information + Cookie: redact # Redact sensitive information + +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" + transport: + respondingTimeouts: + readTimeout: "30m" + http: + tls: + certResolver: "letsencrypt" + middlewares: + - crowdsec@file + +serversTransport: + insecureSkipVerify: true \ No newline at end of file diff --git a/install/fs/docker-compose.yml b/install/config/docker-compose.yml similarity index 93% rename from install/fs/docker-compose.yml rename to install/config/docker-compose.yml index b26e0257..8773b50f 100644 --- a/install/fs/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -11,7 +11,6 @@ services: interval: "3s" timeout: "3s" retries: 5 - {{if .InstallGerbil}} gerbil: image: fosrl/gerbil:{{.GerbilVersion}} @@ -35,15 +34,13 @@ services: - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode {{end}} - traefik: image: traefik:v3.3.3 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service -{{end}} -{{if not .InstallGerbil}} +{{end}}{{if not .InstallGerbil}} ports: - 443:443 - 80:80 @@ -56,6 +53,7 @@ services: volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates + - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs networks: default: diff --git a/install/fs/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml similarity index 100% rename from install/fs/traefik/dynamic_config.yml rename to install/config/traefik/dynamic_config.yml diff --git a/install/fs/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml similarity index 100% rename from install/fs/traefik/traefik_config.yml rename to install/config/traefik/traefik_config.yml diff --git a/install/crowdsec.go b/install/crowdsec.go new file mode 100644 index 00000000..2d56ecc6 --- /dev/null +++ b/install/crowdsec.go @@ -0,0 +1,121 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "strings" +) + +func installCrowdsec(config Config) error { + + if err := stopContainers(); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + + // Run installation steps + if err := backupConfig(); err != nil { + return fmt.Errorf("backup failed: %v", err) + } + + if err := createConfigFiles(config); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) + } + + os.MkdirAll("config/crowdsec/db", 0755) + os.MkdirAll("config/crowdsec_logs/syslog", 0755) + os.MkdirAll("config/traefik/logs", 0755) + + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { + fmt.Printf("Error copying docker service: %v\n", err) + os.Exit(1) + } + + if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil { + fmt.Printf("Error copying entry points: %v\n", err) + os.Exit(1) + } + // delete the 2nd file + if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) + os.Exit(1) + } + + if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil { + fmt.Printf("Error copying entry points: %v\n", err) + os.Exit(1) + } + // delete the 2nd file + if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) + os.Exit(1) + } + + if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil { + fmt.Printf("Error removing file: %v\n", err) + os.Exit(1) + } + + if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil { + fmt.Printf("Error checking and adding Traefik log volume: %v\n", err) + os.Exit(1) + } + + if err := startContainers(); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + // get API key + apiKey, err := GetCrowdSecAPIKey() + if err != nil { + return fmt.Errorf("failed to get API key: %v", err) + } + config.TraefikBouncerKey = apiKey + + if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil { + return fmt.Errorf("failed to replace bouncer key: %v", err) + } + + if err := restartContainer("traefik"); err != nil { + return fmt.Errorf("failed to restart containers: %v", err) + } + + return nil +} + +func checkIsCrowdsecInstalledInCompose() bool { + // Read docker-compose.yml + content, err := os.ReadFile("docker-compose.yml") + if err != nil { + return false + } + + // Check for crowdsec service + return bytes.Contains(content, []byte("crowdsec:")) +} + +func GetCrowdSecAPIKey() (string, error) { + // First, ensure the container is running + if err := waitForContainer("crowdsec"); err != nil { + return "", fmt.Errorf("waiting for container: %w", err) + } + + // Execute the command to get the API key + cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("executing command: %w", err) + } + + // Trim any whitespace from the output + apiKey := strings.TrimSpace(out.String()) + if apiKey == "" { + return "", fmt.Errorf("empty API key returned") + } + + return apiKey, nil +} diff --git a/install/go.mod b/install/go.mod index 85cf49e4..536ac2dd 100644 --- a/install/go.mod +++ b/install/go.mod @@ -5,4 +5,5 @@ go 1.23.0 require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/install/go.sum b/install/go.sum index f05f63b4..3316e039 100644 --- a/install/go.sum +++ b/install/go.sum @@ -2,3 +2,6 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install/input.txt b/install/input.txt new file mode 100644 index 00000000..9bca8081 --- /dev/null +++ b/install/input.txt @@ -0,0 +1,12 @@ +example.com +pangolin.example.com +admin@example.com +yes +admin@example.com +Password123! +Password123! +yes +no +no +no +yes diff --git a/install/main.go b/install/main.go index 4f2deb3a..9064b4f7 100644 --- a/install/main.go +++ b/install/main.go @@ -4,13 +4,16 @@ import ( "bufio" "embed" "fmt" + "io" "io/fs" "os" + "time" "os/exec" "path/filepath" "runtime" "strings" "syscall" + "bytes" "text/template" "unicode" @@ -24,7 +27,7 @@ func loadVersions(config *Config) { config.BadgerVersion = "replaceme" } -//go:embed fs/* +//go:embed config/* var configFiles embed.FS type Config struct { @@ -45,6 +48,8 @@ type Config struct { EmailSMTPPass string EmailNoReply string InstallGerbil bool + TraefikBouncerKey string + DoCrowdsecInstall bool } func main() { @@ -56,9 +61,12 @@ func main() { os.Exit(1) } + var config Config + config.DoCrowdsecInstall = false + // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { - config := collectUserInput(reader) + config = collectUserInput(reader) loadVersions(&config) @@ -67,18 +75,53 @@ func main() { os.Exit(1) } + moveFile("config/docker-compose.yml", "docker-compose.yml") + if !isDockerInstalled() && runtime.GOOS == "linux" { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() } } + + fmt.Println("\n=== Starting installation ===") + + if isDockerInstalled() { + if readBool(reader, "Would you like to install and start the containers?", true) { + pullAndStartContainers() + } + } } else { - fmt.Println("Config file already exists... skipping configuration") + fmt.Println("Looks like you already installed, so I am going to do the setup...") } - if isDockerInstalled() { - if readBool(reader, "Would you like to install and start the containers?", true) { - pullAndStartContainers() + if !checkIsCrowdsecInstalledInCompose() { + fmt.Println("\n=== Crowdsec Install ===") + // check if crowdsec is installed + if readBool(reader, "Would you like to install Crowdsec?", true) { + + if config.DashboardDomain == "" { + traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") + if err != nil { + fmt.Printf("Error reading config: %v\n", err) + return + } + config.DashboardDomain = traefikConfig.DashboardDomain + config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail + config.BadgerVersion = traefikConfig.BadgerVersion + + // print the values and check if they are right + fmt.Println("Detected values:") + fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) + fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) + fmt.Printf("Badger Version: %s\n", config.BadgerVersion) + + if !readBool(reader, "Are these values correct?", true) { + config = collectUserInput(reader) + } + } + + config.DoCrowdsecInstall = true + installCrowdsec(config) } } @@ -99,22 +142,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string return input } -func readPassword(prompt string) string { - fmt.Print(prompt + ": ") - - // Read password without echo - password, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() // Add a newline since ReadPassword doesn't add one - - if err != nil { - return "" - } - - input := strings.TrimSpace(string(password)) - if input == "" { - return readPassword(prompt) - } - return input +func readPassword(prompt string, reader *bufio.Reader) string { + if term.IsTerminal(int(syscall.Stdin)) { + fmt.Print(prompt + ": ") + // Read password without echo if we're in a terminal + password, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() // Add a newline since ReadPassword doesn't add one + if err != nil { + return "" + } + input := strings.TrimSpace(string(password)) + if input == "" { + return readPassword(prompt, reader) + } + return input + } else { + // Fallback to reading from stdin if not in a terminal + return readString(reader, prompt, "") + } } func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { @@ -150,8 +195,8 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Admin User Configuration ===") config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain) for { - pass1 := readPassword("Create admin user password") - pass2 := readPassword("Confirm admin user password") + pass1 := readPassword("Create admin user password", reader) + pass2 := readPassword("Confirm admin user password", reader) if pass1 != pass2 { fmt.Println("Passwords do not match") @@ -261,31 +306,33 @@ func createConfigFiles(config Config) error { os.MkdirAll("config/logs", 0755) // Walk through all embedded files - err := fs.WalkDir(configFiles, "fs", func(path string, d fs.DirEntry, err error) error { + err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip the root fs directory itself - if path == "fs" { + if path == "config" { return nil } - // Get the relative path by removing the "fs/" prefix - relPath := strings.TrimPrefix(path, "fs/") + if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") { + return nil + } + + if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") { + return nil + } // skip .DS_Store - if strings.Contains(relPath, ".DS_Store") { + if strings.Contains(path, ".DS_Store") { return nil } - // 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) + if err := os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %v", path, err) } return nil } @@ -303,14 +350,14 @@ func createConfigFiles(config Config) error { } // 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) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create parent directory for %s: %v", path, err) } // Create output file - outFile, err := os.Create(outPath) + outFile, err := os.Create(path) if err != nil { - return fmt.Errorf("failed to create %s: %v", outPath, err) + return fmt.Errorf("failed to create %s: %v", path, err) } defer outFile.Close() @@ -326,30 +373,10 @@ func createConfigFiles(config Config) error { return fmt.Errorf("error walking config files: %v", err) } - // get the current directory - dir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %v", err) - } - - sourcePath := filepath.Join(dir, "config/docker-compose.yml") - destPath := filepath.Join(dir, "docker-compose.yml") - - // Check if source file exists - if _, err := os.Stat(sourcePath); err != nil { - return fmt.Errorf("source docker-compose.yml not found: %v", err) - } - - // Try to move the file - err = os.Rename(sourcePath, destPath) - if err != nil { - return fmt.Errorf("failed to move docker-compose.yml from %s to %s: %v", - sourcePath, destPath, err) - } - return nil } + func installDocker() error { // Detect Linux distribution cmd := exec.Command("cat", "/etc/os-release") @@ -490,3 +517,166 @@ func pullAndStartContainers() error { return nil } + +// bring containers down +func stopContainers() error { + fmt.Println("Stopping containers...") + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + + return nil +} + +// just start containers +func startContainers() error { + fmt.Println("Starting containers...") + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil +} + +func restartContainer(container string) error { + fmt.Printf("Restarting %s container...\n", container) + + // Check which docker compose command is available + var useNewStyle bool + checkCmd := exec.Command("docker", "compose", "version") + if err := checkCmd.Run(); err == nil { + useNewStyle = true + } else { + // Check if docker-compose (old style) is available + checkCmd = exec.Command("docker-compose", "version") + if err := checkCmd.Run(); err != nil { + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) + } + } + + // Helper function to execute docker compose commands + executeCommand := func(args ...string) error { + var cmd *exec.Cmd + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil { + return fmt.Errorf("failed to restart %s container: %v", container, err) + } + + return nil +} + +func copyFile(src, dst string) error { + source, err := os.Open(src) + if err != nil { + return err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return err + } + defer destination.Close() + + _, err = io.Copy(destination, source) + return err +} + +func moveFile(src, dst string) error { + if err := copyFile(src, dst); err != nil { + return err + } + + return os.Remove(src) +} + +func waitForContainer(containerName string) error { + maxAttempts := 30 + retryInterval := time.Second * 2 + + for attempt := 0; attempt < maxAttempts; attempt++ { + // Check if container is running + cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + // If the container doesn't exist or there's another error, wait and retry + time.Sleep(retryInterval) + continue + } + + isRunning := strings.TrimSpace(out.String()) == "true" + if isRunning { + return nil + } + + // Container exists but isn't running yet, wait and retry + time.Sleep(retryInterval) + } + + return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) +} \ No newline at end of file