diff --git a/.dockerignore b/.dockerignore index a8b39fa6..816d8ee3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,7 +22,6 @@ next-env.d.ts *.log .machinelogs*.json *-audit.json -package-lock.json install/ bruno/ LICENSE diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index be0fc303..bc581582 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -64,7 +64,7 @@ jobs: - name: Build installer working-directory: install run: | - make release + make go-build-release - name: Upload artifacts from /install/bin uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 63742d1c..dd935c30 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ next-env.d.ts .machinelogs*.json *-audit.json migrations -package-lock.json tsconfig.tsbuildinfo config/config.yml dist diff --git a/Dockerfile b/Dockerfile index 4a54d925..daa47e83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,8 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package.json ./ - -RUN npm install +COPY package.json package-lock.json ./ +RUN npm ci COPY . . @@ -14,21 +13,19 @@ RUN npm run build FROM node:20-alpine AS runner -RUN apk add --no-cache curl - WORKDIR /app -COPY package.json ./ +# Curl used for the health checks +RUN apk add --no-cache curl -RUN npm install --omit=dev +COPY package.json package-lock.json ./ +RUN npm ci --only=production && npm cache clean --force -COPY --from=builder /app/.next ./.next +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init -COPY config/config.example.yml ./dist/config.example.yml -COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml -COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml COPY server/db/names.json ./dist/names.json COPY public ./public diff --git a/README.md b/README.md index 61e1a689..707c4b7c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ +
@@ -68,47 +72,27 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment
- Run on any cloud provider or on-premises.
-- Docker Compose based setup for simplified deployment.
+- **Docker Compose based setup** for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
-- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
+- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
### Modular Design
-- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
+- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
+ - **Automatically install and configure Crowdsec via Pangolin's installer script.**
- Attach as many sites to the central server as you wish.
-## Screenshots
-
-![]() |
- ![]() |
- ![]() |
-
| Sites | -Users | -Share Link | -
![]() |
- ![]() |
- - |
| Authentication | -Connectivity | -- |
## Deployment and Usage Example
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 purchase through [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**:
@@ -119,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server.
-4. **Configure Users & Roles**
+4. **Expose Resources**:
- - Define organizations and invite users.
- - Implement user- or role-based permissions to control resource access.
+ - Add resources to the central server and configure access control rules.
+ - Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
@@ -130,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**Use Case Example - IoT Networks**:
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
+
+
+
+_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
+
## Similar Projects and Inspirations
**Cloudflare Tunnels**:
@@ -147,7 +136,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/config/config.example.yml b/config/config.example.yml
index de094f03..d7b70a69 100644
--- a/config/config.example.yml
+++ b/config/config.example.yml
@@ -1,9 +1,16 @@
+# To see all available options, please visit the docs:
+# https://docs.fossorial.io/Pangolin/Configuration/config
+
app:
dashboard_url: "http://localhost:3002"
- base_domain: "localhost"
log_level: "info"
save_logs: false
+domains:
+ domain1:
+ base_domain: "example.com"
+ cert_resolver: "letsencrypt"
+
server:
external_port: 3000
internal_port: 3001
@@ -14,7 +21,6 @@ server:
resource_session_request_param: "p_session_request"
traefik:
- cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
diff --git a/config/traefik/dynamic_config.example.yml b/config/traefik/dynamic_config.example.yml
deleted file mode 100644
index cca0ea18..00000000
--- a/config/traefik/dynamic_config.example.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-http:
- middlewares:
- redirect-to-https:
- redirectScheme:
- scheme: https
-
- routers:
- # HTTP to HTTPS redirect router
- main-app-router-redirect:
- rule: "Host(`{{.DashboardDomain}}`)"
- 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`)"
- service: next-service
- entryPoints:
- - websecure
- tls:
- certResolver: letsencrypt
-
- # API router (handles /api/v1 paths)
- api-router:
- rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
- service: api-service
- entryPoints:
- - websecure
- tls:
- certResolver: letsencrypt
-
- # WebSocket router
- ws-router:
- rule: "Host(`{{.DashboardDomain}}`)"
- service: api-service
- entryPoints:
- - websecure
- tls:
- certResolver: letsencrypt
-
- services:
- next-service:
- loadBalancer:
- servers:
- - url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
-
- api-service:
- loadBalancer:
- servers:
- - url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server
diff --git a/config/traefik/traefik_config.example.yml b/config/traefik/traefik_config.example.yml
deleted file mode 100644
index 01d05903..00000000
--- a/config/traefik/traefik_config.example.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-api:
- insecure: true
- dashboard: true
-
-providers:
- http:
- endpoint: "http://pangolin:{{.INTERNAL_PORT}}/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.3"
-
-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"
- transport:
- respondingTimeouts:
- readTimeout: "30m"
- http:
- tls:
- certResolver: "letsencrypt"
-
-serversTransport:
- insecureSkipVerify: true
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 88%
rename from install/fs/config.yml
rename to install/config/config.yml
index bee904c1..15d48b8d 100644
--- a/install/fs/config.yml
+++ b/install/config/config.yml
@@ -1,9 +1,16 @@
+# To see all available options, please visit the docs:
+# https://docs.fossorial.io/Pangolin/Configuration/config
+
app:
dashboard_url: "https://{{.DashboardDomain}}"
- base_domain: "{{.BaseDomain}}"
log_level: "info"
save_logs: false
+domains:
+ domain1:
+ base_domain: "{{.BaseDomain}}"
+ cert_resolver: "letsencrypt"
+
server:
external_port: 3000
internal_port: 3001
@@ -22,7 +29,6 @@ traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
- prefer_wildcard_cert: false
gerbil:
start_port: 51820
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..1a642ee8
--- /dev/null
+++ b/install/config/crowdsec/docker-compose.yml
@@ -0,0 +1,30 @@
+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"]
+ 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:
+ - 6060:6060 # metrics endpoint for prometheus
+ expose:
+ - 6060 # metrics endpoint for prometheus
+ 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..c545a90d
--- /dev/null
+++ b/install/crowdsec.go
@@ -0,0 +1,137 @@
+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)
+ }
+
+ if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
+ fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
+ fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer")
+ }
+
+ 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
+}
+
+func checkIfTextInFile(file, text string) bool {
+ // Read file
+ content, err := os.ReadFile(file)
+ if err != nil {
+ return false
+ }
+
+ // Check for text
+ return bytes.Contains(content, []byte(text))
+}
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/internationalization/es.md b/internationalization/es.md
new file mode 100644
index 00000000..c4477fbf
--- /dev/null
+++ b/internationalization/es.md
@@ -0,0 +1,291 @@
+## Authentication Site
+
+
+| EN | ES | Notes |
+| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
+| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Desarrollado por [Pangolin](https://github.com/fosrl/pangolin) | |
+| Authentication Required | Se requiere autenticación | |
+| Choose your preferred method to access {resource} | Elije tu método requerido para acceder a {resource} | |
+| PIN | PIN | |
+| User | Usuario | |
+| 6-digit PIN Code | Código PIN de 6 dígitos | pin login |
+| Login in with PIN | Registrate con PIN | pin login |
+| Email | Email | user login |
+| Enter your email | Introduce tu email | user login |
+| Password | Contraseña | user login |
+| Enter your password | Introduce tu contraseña | user login |
+| Forgot your password? | ¿Olvidaste tu contraseña? | user login |
+| Log in | Iniciar sesión | user login |
+
+
+## Login site
+
+| EN | ES | Notes |
+| --------------------- | ---------------------------------- | ----------- |
+| Welcome to Pangolin | Binvenido a Pangolin | |
+| Log in to get started | Registrate para comenzar | |
+| Email | Email | |
+| Enter your email | Introduce tu email | placeholder |
+| Password | Contraseña | |
+| Enter your password | Introduce tu contraseña | placeholder |
+| Forgot your password? | ¿Olvidaste tu contraseña? | |
+| Log in | Iniciar sesión | |
+
+# Ogranization site after successful login
+
+| EN | ES | Notes |
+| ----------------------------------------- | -------------------------------------------- | ----- |
+| Welcome to Pangolin | Binvenido a Pangolin | |
+| You're a member of {number} organization. | Eres miembro de la organización {number}. | |
+
+## Shared Header, Navbar and Footer
+##### Header
+
+| EN | ES | Notes |
+| ------------------- | ------------------- | ----- |
+| Documentation | Documentación | |
+| Support | Soporte | |
+| Organization {name} | Organización {name} | |
+##### Organization selector
+
+| EN | ES | Notes |
+| ---------------- | ----------------- | ----- |
+| Search… | Buscar… | |
+| Create | Crear | |
+| New Organization | Nueva Organización| |
+| Organizations | Organizaciones | |
+
+##### Navbar
+
+| EN | ES | Notes |
+| --------------- | -----------------------| ----- |
+| Sites | Sitios | |
+| Resources | Recursos | |
+| User & Roles | Usuarios y roles | |
+| Shareable Links | Enlaces para compartir | |
+| General | General | |
+
+##### Footer
+| EN | ES | |
+| ------------------------- | --------------------------- | -------|
+| Page {number} of {number} | Página {number} de {number} | footer |
+| Rows per page | Filas por página | footer |
+| Pangolin | Pangolin | footer |
+| Built by Fossorial | Construido por Fossorial | footer |
+| Open Source | Código abierto | footer |
+| Documentation | Documentación | footer |
+| {version} | {version} | footer |
+
+## Main “Sites”
+##### “Hero” section
+
+| EN | ES | Notes |
+| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
+| Newt (Recommended) | Newt (Recomendado) | |
+| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Para obtener la mejor experiencia de usuario, utiliza Newt. Utiliza WireGuard internamente y te permite abordar tus recursos privados mediante tu dirección LAN en tu red privada desde el panel de Pangolin. | |
+| Runs in Docker | Se ejecuta en Docker | |
+| Runs in shell on macOS, Linux, and Windows | Se ejecuta en shell en macOS, Linux y Windows | |
+| Install Newt | Instalar Newt | |
+| Basic WireGuard