diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 65b01b26..be0fc303 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -36,7 +36,7 @@ jobs: run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts - cat server/lib/ + cat server/lib/consts.ts - name: Pull latest Gerbil version id: get-gerbil-tag diff --git a/README.md b/README.md index 61e1a689..5baef277 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ +
@@ -108,7 +108,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
1. **Deploy the Central Server**:
- - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
+ - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
+
+> [!TIP]
+> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
+> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
2. **Domain Configuration**:
@@ -147,7 +151,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
## Licensing
-Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
+Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
## Contributions
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
index bc5ad10c..ad755174 100644
--- a/docker-compose.example.yml
+++ b/docker-compose.example.yml
@@ -1,5 +1,4 @@
-version: "3.7"
-
+name: pangolin
services:
pangolin:
image: fosrl/pangolin:latest
@@ -32,7 +31,6 @@ services:
- 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
@@ -47,8 +45,8 @@ services:
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
+ - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
+ - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:
diff --git a/install/Makefile b/install/Makefile
index e8e9cd2e..9bde02cf 100644
--- a/install/Makefile
+++ b/install/Makefile
@@ -1,13 +1,24 @@
-all: build
+all: update-versions go-build-release put-back
-build:
- CGO_ENABLED=0 go build -o bin/installer
-
-release:
+go-build-release:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
- rm -f bin/installer
rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64
+
+update-versions:
+ @echo "Fetching latest versions..."
+ cp main.go main.go.bak && \
+ PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \
+ GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
+ BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
+ echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
+ sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
+ sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
+ sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
+ echo "Updated main.go with latest versions"
+
+put-back:
+ mv main.go.bak main.go
\ No newline at end of file
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 92%
rename from install/fs/docker-compose.yml
rename to install/config/docker-compose.yml
index ea673eb0..8773b50f 100644
--- a/install/fs/docker-compose.yml
+++ b/install/config/docker-compose.yml
@@ -1,3 +1,4 @@
+name: pangolin
services:
pangolin:
image: fosrl/pangolin:{{.PangolinVersion}}
@@ -10,7 +11,6 @@ services:
interval: "3s"
timeout: "3s"
retries: 5
-
{{if .InstallGerbil}}
gerbil:
image: fosrl/gerbil:{{.GerbilVersion}}
@@ -34,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
@@ -55,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
diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts
index 18ea072b..62850453 100644
--- a/server/auth/sessions/app.ts
+++ b/server/auth/sessions/app.ts
@@ -11,7 +11,7 @@ import {
users
} from "@server/db/schema";
import db from "@server/db";
-import { eq } from "drizzle-orm";
+import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
@@ -95,12 +95,36 @@ export async function validateSessionToken(
}
export async function invalidateSession(sessionId: string): Promise