diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cdafc0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +dist +goup +goup-dev +goup-web +goup-dns +*.log +tests +docs diff --git a/.github/workflows/continuous.yml b/.github/workflows/continuous.yml index 24962d5..dc6812e 100644 --- a/.github/workflows/continuous.yml +++ b/.github/workflows/continuous.yml @@ -3,11 +3,12 @@ name: Build on: push: branches: ["main"] + pull_request: + branches: ["main"] jobs: - build: + quality: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -16,13 +17,39 @@ jobs: with: go-version-file: go.mod - - name: Test - run: go test -v ./... + - name: gofmt + run: | + fmtout=$(gofmt -l $(git ls-files '*.go')) + if [ -n "$fmtout" ]; then echo "Unformatted files:"; echo "$fmtout"; exit 1; fi + + - name: go vet + run: go vet ./... + + - name: staticcheck + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... + + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + - name: Test (race) + run: go test -race ./... + + build: + needs: quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod - name: Build run: go build -o goup cmd/goup/main.go - uses: softprops/action-gh-release@v1 + if: github.event_name == 'push' with: token: "${{ secrets.GITHUB_TOKEN }}" tag_name: "continuous" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a1f302..3d8e124 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: goreleaser +name: release on: push: @@ -46,7 +46,13 @@ jobs: CGO_ENABLED: 0 run: | mkdir -p dist - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -o dist/goup-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }} cmd/goup/main.go + VERSION="${GITHUB_REF_NAME}" + COMMIT="$(git rev-parse --short HEAD)" + DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + LDFLAGS="-s -w -X github.com/mirkobrombin/goup/internal/cli.Version=${VERSION} -X github.com/mirkobrombin/goup/internal/cli.Commit=${COMMIT} -X github.com/mirkobrombin/goup/internal/cli.Date=${DATE}" + OUT="dist/goup-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags "${LDFLAGS}" -o "${OUT}" cmd/goup/main.go + sha256sum "${OUT}" | awk '{print $1}' > "${OUT}.sha256" - uses: softprops/action-gh-release@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b713afd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added +- Automatic TLS via ACME/Let's Encrypt (TLS-ALPN-01) with on-disk certificate + caching and renewal (`ssl.acme`, `ssl.email`, `ssl.cache_dir`). +- Reverse-proxy load balancing across multiple upstreams with passive health + checks, ejection and failover (`proxy_upstreams`). +- Per-IP rate limiting (`rate_limit_rps`, `rate_limit_burst`). +- IP allow/deny lists (`allow_ips`, `deny_ips`). +- Request body size limit (`max_body_bytes`). +- CORS handling (`cors`) and configurable security headers (`security_headers`, + `hsts`, `hsts_max_age`). +- HTTP-to-HTTPS redirect (`force_https`) and `Cache-Control` for static assets + (`cache_control`). +- `goup version` command and `--version` flag, with version metadata injected at + release time. +- Configuration hot reload on `SIGHUP` (and `systemctl reload`). +- Log retention purge (`log_retention_days`). +- systemd unit, Dockerfile, and a production deployment guide. +- Bind-address options for the API and Dashboard (`api_bind`, `dashboard_bind`). + +### Changed +- `goup validate` now performs real semantic validation: rejects unknown JSON + fields, checks certificate/root paths, port ranges, and cross-site conflicts. +- CI now runs gofmt, vet, staticcheck, govulncheck, and race-enabled tests; + release builds embed version metadata and publish SHA-256 checksums. + +### Fixed +- Authentication for the API and Dashboard now fails closed when credentials are + unset, and refuses to start without them. +- Virtual-host servers select the correct TLS certificate by SNI instead of + silently serving plaintext. +- Auth-plugin sessions are bound to a random cookie token instead of the + spoofable client IP. +- Plugin child processes (Node.js, Python, Docker) are terminated on shutdown and + restart instead of being orphaned. +- On-the-fly gzip no longer breaks HTTP 304 revalidation. +- API and Dashboard servers now set read/idle timeouts (slowloris hardening). +- Fixed a goroutine/buffer leak when logging spawned-process output. +- DNS zone matching respects label boundaries; the forwarder enforces a + recursion ACL and truncates oversized UDP responses. +- Path traversal hardening across the API, config loading, and static serving. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..179289d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Build stage +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ARG VERSION=docker +ARG COMMIT=none +ARG DATE=unknown +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X github.com/mirkobrombin/goup/internal/cli.Version=${VERSION} -X github.com/mirkobrombin/goup/internal/cli.Commit=${COMMIT} -X github.com/mirkobrombin/goup/internal/cli.Date=${DATE}" \ + -o /out/goup cmd/goup/main.go + +# Runtime stage +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/goup /usr/local/bin/goup +# Config in /etc/goup, logs/state in /var/lib/goup (XDG roots set to /etc and /var/lib). +ENV XDG_CONFIG_HOME=/etc +ENV XDG_DATA_HOME=/var/lib +VOLUME ["/etc/goup", "/var/lib/goup"] +ENTRYPOINT ["/usr/local/bin/goup"] +CMD ["start"] diff --git a/README.md b/README.md index f1d33b9..4354b4f 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,15 @@ GoUP! is a minimal, tweakable web server written in Go. You can use it to serve ## Features - Serve static files from a specified root directory -- Set up reverse proxies to backend services -- Support for SSL/TLS with custom certificates -- Custom headers for HTTP responses +- Reverse proxy to a single backend or load-balance across several (`proxy_upstreams`) with health checks and failover +- SSL/TLS with custom certificates or automatic Let's Encrypt certificates (`ssl.acme`) +- HTTP-to-HTTPS redirect, HSTS, and configurable security headers +- Edge hardening: per-IP rate limiting, IP allow/deny lists, request body limits, CORS +- Custom headers and `Cache-Control` for HTTP responses - Support for multiple domains and virtual hosting - Native Authoritative DNS Server (A, AAAA, CNAME, TXT, MX, NS) -- Logging to both console and files - JSON formatted (structured logs) +- Logging to both console and files - JSON formatted (structured logs), with date rotation and retention +- Zero-downtime config reload (`SIGHUP`), graceful shutdown, and a memory watchdog (SafeGuard) - Optional TUI interface for real-time monitoring - HTTP/2 and HTTP/3 support (not configurable, HTTP/1.1 is used for unencrypted connections, HTTP/2 and HTTP/3 for encrypted connections) @@ -182,13 +185,21 @@ goup start --tui - **Stop the Server:** ```bash - goup stop // Not implemented yet, use to stop the server + goup stop ``` - **Restart the Server:** ```bash - goup restart // Not implemented yet, use to stop the server and start it again + goup restart + # Or, if running under systemd, reload without downtime: + # systemctl reload goup (sends SIGHUP) + ``` + +- **Print the Version:** + + ```bash + goup version ``` - **Generate Password Hash:** @@ -230,11 +241,50 @@ Each site configuration is represented by a JSON file and meets the following st - **root_directory**: Path to the directory containing static files. Leave empty if using `proxy_pass` - **custom_headers**: Key-value pairs of custom headers to include in responses - **proxy_pass**: URL to the backend service for reverse proxying. Leave empty if serving static files +- **proxy_upstreams**: List of backend URLs to load-balance across (round-robin with failover). Takes precedence over `proxy_pass` - **ssl**: - **enabled**: Set to `true` to enable SSL/TLS - **certificate**: Path to the SSL certificate file - **key**: Path to the SSL key file -- **request_timeout**: Timeout for client requests in seconds + - **acme**: Set to `true` to obtain and renew a Let's Encrypt certificate automatically (ignores certificate/key). Requires the domain to resolve to this host and port 443 to be reachable + - **email**: ACME account email (recommended) + - **cache_dir**: Where issued certificates are cached +- **request_timeout**: Read timeout for client requests in seconds (default 60; `-1` disables it) + +**Additional site fields (all optional):** + +| Field | Type | Description | +|---|---|---| +| `read_header_timeout` | int (s) | Header-read timeout (default 10) | +| `idle_timeout` | int (s) | Keep-alive idle timeout (default 120) | +| `max_header_bytes` | int | Maximum request header size | +| `max_body_bytes` | int | Maximum request body size (0 = unlimited) | +| `proxy_flush_interval` | duration | Reverse-proxy flush interval (e.g. `"100ms"`) | +| `buffer_size_kb` | int | Reverse-proxy copy buffer size in KB | +| `max_concurrent_connections` | int | Cap on in-flight requests (503 when exceeded) | +| `enable_logging` | bool | Per-site access logging (default true) | +| `file_server_mode` | bool | Plain directory listing, no branded pages | +| `force_https` | bool | Redirect plain HTTP to HTTPS (put on the :80 site) | +| `hsts` | bool | Send `Strict-Transport-Security` when served over TLS | +| `hsts_max_age` | int (s) | HSTS max-age (default 31536000) | +| `security_headers` | bool | Add `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` | +| `cache_control` | string | `Cache-Control` value for static responses | +| `allow_ips` / `deny_ips` | []CIDR | IP allow/deny lists | +| `rate_limit_rps` | float | Per-IP requests/second (0 = disabled) | +| `rate_limit_burst` | int | Per-IP burst size | +| `cors` | object | CORS: `allowed_origins`, `allowed_methods`, `allowed_headers`, `allow_credentials`, `max_age` | +| `plugin_configs` | object | Per-plugin configuration (see Plugins) | + +Global settings (`conf.global.json`) also support `api_bind` / `dashboard_bind` +(bind addresses, empty = all interfaces) and `log_retention_days` (auto-purge +logs older than N days, 0 = keep forever). + +Run `goup validate` to check every config file: it reports JSON typos (unknown +fields), missing certificate/root paths, invalid ports, and cross-site conflicts +(duplicate domains, or a port mixing SSL and non-SSL sites). + +See [Running in production](docs/production.md) for systemd, Docker, non-root +port binding, and zero-downtime reloads. ## Logging @@ -431,6 +481,6 @@ GoUP! is released under the [MIT License](LICENSE). --- -**Note:** This project is for educational purposes and may not be suitable -for production environments without additional security and performance -considerations, yet. +**Note:** Review the [production guide](docs/production.md) before deploying: +run GoUp as a non-root service, secure the API/Dashboard with credentials, and +enable TLS, HSTS, and rate limiting for internet-facing sites. diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..de5fde1 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,84 @@ +# Running GoUp in production + +This guide covers running GoUp as a long-lived service: as a systemd unit, in a +container, binding privileged ports without root, reloading configuration, and +log retention. + +## Directory layout + +GoUp resolves its paths from the XDG environment variables: + +- Configuration: `$XDG_CONFIG_HOME/goup` (per-site `*.json` plus `conf.global.json`) +- Logs and state: `$XDG_DATA_HOME/goup/logs` + +For a system service the recommended layout is: + +- `XDG_CONFIG_HOME=/etc` so configuration lives in `/etc/goup` +- `XDG_DATA_HOME=/var/lib` so logs live in `/var/lib/goup/logs` + +## systemd + +A ready-to-use unit is provided at [`init/goup.service`](../init/goup.service). + +```bash +# Create a dedicated service user and directories +sudo useradd --system --home /var/lib/goup --shell /usr/sbin/nologin goup +sudo mkdir -p /etc/goup /var/lib/goup +sudo chown -R goup:goup /etc/goup /var/lib/goup + +# Install the binary and the unit +sudo install -m 0755 goup /usr/local/bin/goup +sudo install -m 0644 init/goup.service /etc/systemd/system/goup.service + +sudo systemctl daemon-reload +sudo systemctl enable --now goup +``` + +The unit grants `CAP_NET_BIND_SERVICE`, so GoUp can bind ports 80 and 443 +without running as root. + +Reload configuration and certificates without downtime: + +```bash +sudo systemctl reload goup # sends SIGHUP +``` + +## Binding ports 80/443 as non-root + +If you do not use the systemd unit, grant the capability directly to the binary: + +```bash +sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/goup +``` + +## Docker + +```bash +docker build -t goup . +docker run -d --name goup \ + -p 80:80 -p 443:443 \ + -v /etc/goup:/etc/goup \ + -v /var/lib/goup:/var/lib/goup \ + goup +``` + +The image is based on distroless `nonroot`; mount your configuration into +`/etc/goup`. + +## HTTP to HTTPS redirect + +Create two site configs for the same domain: one on port 80 with +`"force_https": true`, and one on port 443 with TLS enabled. Requests to the +plain-HTTP listener are 301-redirected to HTTPS. Enable `"hsts": true` on the +HTTPS site to send `Strict-Transport-Security`. + +## Log retention + +Set `log_retention_days` in `conf.global.json` to automatically delete log files +(and heap dumps) older than N days. `0` (the default) keeps logs forever. + +## Health checks + +Every site exposes an unauthenticated `/up` endpoint that returns `200 ok` when +ready and `503` while the server is draining during shutdown. Point your load +balancer or orchestrator liveness/readiness probe at it. diff --git a/init/goup.service b/init/goup.service new file mode 100644 index 0000000..50573f3 --- /dev/null +++ b/init/goup.service @@ -0,0 +1,35 @@ +[Unit] +Description=GoUp web server +Documentation=https://github.com/mirkobrombin/goup +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=goup +Group=goup + +# Configuration lives in /etc/goup, state/logs in /var/lib/goup. +Environment=XDG_CONFIG_HOME=/etc +Environment=XDG_DATA_HOME=/var/lib + +ExecStart=/usr/local/bin/goup start +# Reload configuration and certificates without a full restart. +ExecReload=/bin/kill -HUP $MAINPID + +Restart=on-failure +RestartSec=2 + +# Bind privileged ports (80/443) without running as root. +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true + +# Filesystem hardening. +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/etc/goup /var/lib/goup + +[Install] +WantedBy=multi-user.target diff --git a/internal/api/server.go b/internal/api/server.go index 8d989b7..0f26e79 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -3,6 +3,7 @@ package api import ( "fmt" "net/http" + "time" "github.com/mirkobrombin/goup/internal/config" "github.com/mirkobrombin/goup/internal/middleware" @@ -33,8 +34,13 @@ func StartAPIServer() *http.Server { handler = middleware.TokenAuthMiddleware(handler) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), + Addr: fmt.Sprintf("%s:%d", conf.APIBind, port), Handler: handler, + // Timeouts guard the (pre-auth) admin surface against slowloris. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } go func() { diff --git a/internal/api/sites.go b/internal/api/sites.go index 443b5ab..4c3f45a 100644 --- a/internal/api/sites.go +++ b/internal/api/sites.go @@ -4,9 +4,7 @@ import ( "encoding/json" "net/http" "os" - "path/filepath" "sort" - "strings" "github.com/gorilla/mux" "github.com/mirkobrombin/goup/internal/config" @@ -134,26 +132,7 @@ func validateSiteHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Site not found", http.StatusNotFound) return } - var errs []string - if site.SSL.Enabled { - if exists, invalid := checkConfiguredPath(site.SSL.Certificate); invalid { - errs = append(errs, "SSL certificate path is invalid (must be an absolute path without '..')") - } else if !exists { - errs = append(errs, "SSL certificate not found") - } - if exists, invalid := checkConfiguredPath(site.SSL.Key); invalid { - errs = append(errs, "SSL key path is invalid (must be an absolute path without '..')") - } else if !exists { - errs = append(errs, "SSL key not found") - } - } - if site.RootDirectory != "" { - if exists, invalid := checkConfiguredPath(site.RootDirectory); invalid { - errs = append(errs, "Root directory path is invalid (must be an absolute path without '..')") - } else if !exists { - errs = append(errs, "Root directory does not exist") - } - } + errs := site.Validate() if len(errs) > 0 { jsonResponse(w, map[string]any{ "valid": false, @@ -163,21 +142,3 @@ func validateSiteHandler(w http.ResponseWriter, r *http.Request) { } jsonResponse(w, map[string]any{"valid": true}) } - -// checkConfiguredPath validates an operator-supplied filesystem path (SSL -// certificate, key, or root directory) and reports whether it exists. It -// rejects relative paths and any path containing a ".." traversal segment -// before touching the filesystem, so request-derived data is sanitised before -// it reaches os.Stat. Returns invalid=true when the path fails validation. -func checkConfiguredPath(p string) (exists bool, invalid bool) { - if p == "" { - return false, false - } - if strings.Contains(p, "..") || !filepath.IsAbs(p) { - return false, true - } - if _, err := os.Stat(p); err == nil { - return true, false - } - return false, false -} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1a41a49..8921b17 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -12,6 +12,7 @@ import ( "github.com/mirkobrombin/goup/internal/config" "github.com/mirkobrombin/goup/internal/pidfile" "github.com/mirkobrombin/goup/internal/plugin" + "github.com/mirkobrombin/goup/internal/restart" "github.com/mirkobrombin/goup/internal/server" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" @@ -23,11 +24,19 @@ var benchMode bool var configPath string var globalConfigPath string +// Build metadata, injected at release time via -ldflags -X. +var ( + Version = "dev" + Commit = "none" + Date = "unknown" +) + // rootCmd represents the base command when called without any subcommands. var rootCmd = &cobra.Command{ - Use: "goup", - Short: "GoUP is a minimal configurable web server", - Long: `GoUP is a minimal configurable web server written in Go.`, + Use: "goup", + Short: "GoUP is a minimal configurable web server", + Long: `GoUP is a minimal configurable web server written in Go.`, + Version: fmt.Sprintf("%s (commit %s, built %s)", Version, Commit, Date), PersistentPreRun: func(cmd *cobra.Command, args []string) { if err := config.LoadGlobalConfig(globalConfigPath); err != nil { fmt.Printf("Error loading global config: %v\n", err) @@ -55,6 +64,7 @@ func init() { rootCmd.AddCommand(pluginsCmd) rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(restartCmd) + rootCmd.AddCommand(versionCmd) startCmd.Flags().BoolVarP(&tuiMode, "tui", "t", false, "Enable TUI mode") startCmd.Flags().BoolVarP(&benchMode, "bench", "b", false, "Enable benchmark mode") @@ -273,6 +283,17 @@ func handleShutdownSignal() { pidfile.Remove() os.Exit(0) }() + + // SIGHUP reloads configuration: gracefully drain and re-exec, so config and + // certificate changes are picked up without a manual stop/start. + hupCh := make(chan os.Signal, 1) + signal.Notify(hupCh, syscall.SIGHUP) + go func() { + for range hupCh { + fmt.Println("\nReloading configuration (SIGHUP)...") + restart.Restart() + } + }() } func loadConfigs() ([]config.SiteConfig, error) { @@ -392,36 +413,46 @@ var validateCmd = &cobra.Command{ Run: validate, } +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the GoUp version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("goup %s\ncommit: %s\nbuilt: %s\n", Version, Commit, Date) + }, +} + func validate(cmd *cobra.Command, args []string) { configDir := config.GetConfigDir() - files, err := os.ReadDir(configDir) + fmt.Printf("Validating configurations in %s:\n", configDir) + + fileErrors, crossErrors, err := config.ValidateAll() if err != nil { fmt.Printf("Error reading configuration directory %s: %v\n", configDir, err) os.Exit(1) } - fmt.Printf("Validating configurations in %s:\n", configDir) - hasError := false + // Report each file. ValidateAll only returns entries with problems, so read + // the directory to also print the OK ones. + files, _ := os.ReadDir(configDir) + hasError := len(crossErrors) > 0 for _, file := range files { - if filepath.Ext(file.Name()) != ".json" { - continue - } - if file.Name() == "conf.global.json" { - continue - } - fullPath, err := config.SafeJoin(configDir, file.Name()) - if err != nil { - fmt.Printf("- %s: INVALID PATH (%v)\n", file.Name(), err) - hasError = true + name := file.Name() + if file.IsDir() || filepath.Ext(name) != ".json" || name == "conf.global.json" { continue } - conf, err := config.LoadConfig(fullPath) - if err != nil { - fmt.Printf("- %s: FAILED (%v)\n", file.Name(), err) + if problems, bad := fileErrors[name]; bad { hasError = true - continue + fmt.Printf("- %s: FAILED\n", name) + for _, p := range problems { + fmt.Printf(" - %s\n", p) + } + } else { + fmt.Printf("- %s: OK\n", name) } - fmt.Printf("- %s: OK\n", conf.Domain) + } + + for _, c := range crossErrors { + fmt.Printf("! %s\n", c) } if hasError { diff --git a/internal/config/config.go b/internal/config/config.go index a6c3fef..54d24d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,11 @@ type SSLConfig struct { Enabled bool `json:"enabled"` Certificate string `json:"certificate"` Key string `json:"key"` + // ACME enables automatic TLS via Let's Encrypt (TLS-ALPN-01 on :443). + // When set, Certificate/Key are ignored for this site. + ACME bool `json:"acme"` + Email string `json:"email"` // ACME account email (recommended) + CacheDir string `json:"cache_dir"` // where issued certificates are cached } // SiteConfig contains the configuration for a single site. @@ -32,6 +37,7 @@ type SiteConfig struct { RootDirectory string `json:"root_directory"` CustomHeaders map[string]string `json:"custom_headers"` ProxyPass string `json:"proxy_pass"` + ProxyUpstreams []string `json:"proxy_upstreams"` // load-balance across these backends SSL SSLConfig `json:"ssl"` RequestTimeout int `json:"request_timeout"` // in seconds ReadHeaderTimeout int `json:"read_header_timeout"` // in seconds @@ -43,9 +49,31 @@ type SiteConfig struct { EnableLogging *bool `json:"enable_logging,omitempty"` // Default true if nil FileServerMode bool `json:"file_server_mode"` // Disables custom pages, enables directory listing + // Edge hardening and HTTP feature knobs. + MaxBodyBytes int64 `json:"max_body_bytes"` // 0 = default (10MB), -1 = unlimited + ForceHTTPS bool `json:"force_https"` // redirect plain HTTP to HTTPS + HSTS bool `json:"hsts"` // send Strict-Transport-Security when served over TLS + HSTSMaxAge int `json:"hsts_max_age"` // seconds (default 31536000) + SecurityHeaders bool `json:"security_headers"` // X-Content-Type-Options, X-Frame-Options, Referrer-Policy + CacheControl string `json:"cache_control"` // Cache-Control value applied to static responses + AllowIPs []string `json:"allow_ips"` // CIDR allowlist (if set, only these may connect) + DenyIPs []string `json:"deny_ips"` // CIDR denylist + RateLimitRPS float64 `json:"rate_limit_rps"` // per-IP requests/sec (0 = disabled) + RateLimitBurst int `json:"rate_limit_burst"` // per-IP burst size + CORS *CORSConfig `json:"cors,omitempty"` + PluginConfigs map[string]any `json:"plugin_configs"` } +// CORSConfig configures Cross-Origin Resource Sharing for a site. +type CORSConfig struct { + AllowedOrigins []string `json:"allowed_origins"` // "*" allowed + AllowedMethods []string `json:"allowed_methods"` + AllowedHeaders []string `json:"allowed_headers"` + AllowCredentials bool `json:"allow_credentials"` + MaxAge int `json:"max_age"` // preflight cache seconds +} + // GetConfigDir returns the directory where configuration files are stored. func GetConfigDir() string { var configDir string @@ -59,6 +87,20 @@ func GetConfigDir() string { return configDir } +// GetACMEDir returns the default directory where ACME-issued certificates are +// cached. +func GetACMEDir() string { + var base string + if xdgDataHome := os.Getenv("XDG_DATA_HOME"); xdgDataHome != "" { + base = filepath.Join(xdgDataHome, "goup") + } else if runtime.GOOS == "windows" { + base = filepath.Join(os.Getenv("APPDATA"), "goup") + } else { + base = filepath.Join(os.Getenv("HOME"), ".local", "share", "goup") + } + return filepath.Join(base, "acme") +} + // GetLogDir returns the directory where log files are stored. func GetLogDir() string { if customLogDir != "" { diff --git a/internal/config/global_config.go b/internal/config/global_config.go index d459608..dc85e94 100644 --- a/internal/config/global_config.go +++ b/internal/config/global_config.go @@ -22,13 +22,16 @@ type AccountConfig struct { // GlobalConfig contains the global settings for GoUP. type GlobalConfig struct { - Account AccountConfig `json:"account"` - EnableAPI bool `json:"enable_api"` - APIPort int `json:"api_port"` - DashboardPort int `json:"dashboard_port"` - EnabledPlugins []string `json:"enabled_plugins"` // empty means all enabled - SafeGuard SafeGuardConfig `json:"safeguard"` - DNS *DNSConfig `json:"dns"` + Account AccountConfig `json:"account"` + EnableAPI bool `json:"enable_api"` + APIPort int `json:"api_port"` + APIBind string `json:"api_bind"` // bind address for the API (empty = all interfaces) + DashboardPort int `json:"dashboard_port"` + DashboardBind string `json:"dashboard_bind"` // bind address for the dashboard (empty = all interfaces) + EnabledPlugins []string `json:"enabled_plugins"` // empty means all enabled + SafeGuard SafeGuardConfig `json:"safeguard"` + DNS *DNSConfig `json:"dns"` + LogRetentionDays int `json:"log_retention_days"` // delete logs older than N days (0 = keep forever) } // GlobalConf is the global configuration in memory. diff --git a/internal/config/paths.go b/internal/config/paths.go index f6e8e74..df57d58 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "path/filepath" "strings" ) @@ -51,6 +52,24 @@ func SiteConfigPath(domain string) (string, error) { return SafeJoin(GetConfigDir(), domain+".json") } +// CheckPath validates an operator-supplied filesystem path and reports whether +// it exists. It rejects relative paths and any path containing a ".." traversal +// segment before touching the filesystem, so request- or config-derived data is +// sanitised before it reaches os.Stat. invalid is true when the path fails +// validation (as opposed to simply not existing). +func CheckPath(p string) (exists bool, invalid bool) { + if p == "" { + return false, false + } + if strings.Contains(p, "..") || !filepath.IsAbs(p) { + return false, true + } + if _, err := os.Stat(p); err == nil { + return true, false + } + return false, false +} + // SafeJoin joins base with an untrusted relative path and verifies that the // result does not escape base. It is the guard used for every file path built // from request- or config-supplied data. diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..c1c9bce --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,139 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "time" +) + +// Validate performs semantic validation of a site configuration and returns a +// list of human-readable problems (empty = valid). It is shared by the CLI +// `validate` command and the API validation endpoint so both agree on what a +// valid site looks like. +func (c SiteConfig) Validate() []string { + var errs []string + + if err := ValidateDomain(c.Domain); err != nil { + errs = append(errs, "domain: "+err.Error()) + } + if c.Port < 1 || c.Port > 65535 { + errs = append(errs, fmt.Sprintf("port %d is out of range (1-65535)", c.Port)) + } + + if c.ProxyPass == "" && c.RootDirectory == "" && len(c.ProxyUpstreams) == 0 { + errs = append(errs, "either proxy_pass, proxy_upstreams or root_directory must be set") + } + if c.ProxyPass != "" { + if u, err := url.Parse(c.ProxyPass); err != nil || u.Scheme == "" || u.Host == "" { + errs = append(errs, "proxy_pass must be an absolute URL (e.g. http://localhost:3000)") + } + } + for _, up := range c.ProxyUpstreams { + if u, err := url.Parse(up); err != nil || u.Scheme == "" || u.Host == "" { + errs = append(errs, "proxy_upstreams contains an invalid URL: "+up) + } + } + + // Only a static site (no proxy) needs a readable root directory. + if c.RootDirectory != "" && c.ProxyPass == "" && len(c.ProxyUpstreams) == 0 { + exists, invalid := CheckPath(c.RootDirectory) + switch { + case invalid: + errs = append(errs, "root_directory must be an absolute path without '..'") + case !exists: + errs = append(errs, "root_directory does not exist") + } + } + + if c.SSL.Enabled { + if c.SSL.Certificate == "" || c.SSL.Key == "" { + errs = append(errs, "ssl.enabled requires both certificate and key") + } else { + if exists, invalid := CheckPath(c.SSL.Certificate); invalid || !exists { + errs = append(errs, "ssl certificate not found (must be an absolute path)") + } + if exists, invalid := CheckPath(c.SSL.Key); invalid || !exists { + errs = append(errs, "ssl key not found (must be an absolute path)") + } + } + } + + if c.FlushInterval != "" { + if _, err := time.ParseDuration(c.FlushInterval); err != nil { + errs = append(errs, "proxy_flush_interval is not a valid duration (e.g. \"100ms\")") + } + } + + return errs +} + +// StrictParseSiteConfig parses a site config file rejecting unknown fields, so a +// typo like "prot" instead of "port" is reported instead of silently ignored. +func StrictParseSiteConfig(path string) (SiteConfig, error) { + var conf SiteConfig + data, err := os.ReadFile(path) + if err != nil { + return conf, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&conf); err != nil { + return conf, err + } + return conf, nil +} + +// ValidateAll validates every site config file in the config directory and +// returns a per-file map of problems plus any cross-site conflicts (duplicate +// domains, or a port shared by sites that disagree on SSL). +func ValidateAll() (fileErrors map[string][]string, crossErrors []string, err error) { + dir := GetConfigDir() + entries, err := os.ReadDir(dir) + if err != nil { + return nil, nil, err + } + + fileErrors = make(map[string][]string) + seenDomains := make(map[string]string) + portSSL := make(map[int]*bool) + + for _, e := range entries { + name := e.Name() + if e.IsDir() || name == globalConfName || len(name) < 6 || name[len(name)-5:] != ".json" { + continue + } + full, jerr := SafeJoin(dir, name) + if jerr != nil { + fileErrors[name] = []string{jerr.Error()} + continue + } + conf, perr := StrictParseSiteConfig(full) + if perr != nil { + fileErrors[name] = []string{"parse error: " + perr.Error()} + continue + } + if problems := conf.Validate(); len(problems) > 0 { + fileErrors[name] = problems + } + + if prev, ok := seenDomains[conf.Domain]; ok { + crossErrors = append(crossErrors, fmt.Sprintf("duplicate domain %q in %s and %s", conf.Domain, prev, name)) + } else { + seenDomains[conf.Domain] = name + } + + ssl := conf.SSL.Enabled + if prev, ok := portSSL[conf.Port]; ok { + if *prev != ssl { + crossErrors = append(crossErrors, fmt.Sprintf("port %d mixes SSL and non-SSL sites", conf.Port)) + } + } else { + portSSL[conf.Port] = &ssl + } + } + + return fileErrors, crossErrors, nil +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..759cd4f --- /dev/null +++ b/internal/config/validate_test.go @@ -0,0 +1,63 @@ +package config + +import "testing" + +func TestSiteConfigValidate(t *testing.T) { + cases := []struct { + name string + conf SiteConfig + wantErrs bool + }{ + { + name: "missing target", + conf: SiteConfig{Domain: "example.com", Port: 80}, + wantErrs: true, + }, + { + name: "bad port", + conf: SiteConfig{Domain: "example.com", Port: 0, ProxyPass: "http://localhost:3000"}, + wantErrs: true, + }, + { + name: "bad proxy url", + conf: SiteConfig{Domain: "example.com", Port: 80, ProxyPass: "not-a-url"}, + wantErrs: true, + }, + { + name: "invalid upstream", + conf: SiteConfig{Domain: "example.com", Port: 80, ProxyUpstreams: []string{"http://ok:1", "nope"}}, + wantErrs: true, + }, + { + name: "ssl without cert", + conf: SiteConfig{Domain: "example.com", Port: 443, ProxyPass: "http://localhost:3000", SSL: SSLConfig{Enabled: true}}, + wantErrs: true, + }, + { + name: "bad flush interval", + conf: SiteConfig{Domain: "example.com", Port: 80, ProxyPass: "http://localhost:3000", FlushInterval: "notaduration"}, + wantErrs: true, + }, + { + name: "valid proxy site", + conf: SiteConfig{Domain: "example.com", Port: 80, ProxyPass: "http://localhost:3000"}, + wantErrs: false, + }, + { + name: "valid load-balanced site", + conf: SiteConfig{Domain: "example.com", Port: 80, ProxyUpstreams: []string{"http://a:1", "http://b:2"}}, + wantErrs: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + errs := c.conf.Validate() + if c.wantErrs && len(errs) == 0 { + t.Errorf("expected validation errors, got none") + } + if !c.wantErrs && len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + }) + } +} diff --git a/internal/dashboard/server.go b/internal/dashboard/server.go index 3e28341..6d60e71 100644 --- a/internal/dashboard/server.go +++ b/internal/dashboard/server.go @@ -3,6 +3,7 @@ package dashboard import ( "fmt" "net/http" + "time" "github.com/mirkobrombin/goup/internal/config" "github.com/mirkobrombin/goup/internal/middleware" @@ -32,8 +33,13 @@ func StartDashboardServer() *http.Server { handler = middleware.BasicAuthMiddleware(handler) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), + Addr: fmt.Sprintf("%s:%d", conf.DashboardBind, port), Handler: handler, + // Timeouts guard the (pre-auth) admin surface against slowloris. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } go func() { diff --git a/internal/dns/handler.go b/internal/dns/handler.go index 6ac40c0..0c29a88 100644 --- a/internal/dns/handler.go +++ b/internal/dns/handler.go @@ -271,7 +271,20 @@ func (h *DNSHandler) handleForwarding(w dns.ResponseWriter, r *dns.Msg) { resp, _, err := h.client.Exchange(r, target) if err == nil { resp.Authoritative = false - w.WriteMsg(resp) + // Truncate to the client's UDP buffer and set the TC bit so it + // retries over TCP instead of receiving an oversized datagram. + if _, isUDP := w.RemoteAddr().(*net.UDPAddr); isUDP { + maxSize := dns.MinMsgSize + if opt := r.IsEdns0(); opt != nil { + if sz := int(opt.UDPSize()); sz > maxSize { + maxSize = sz + } + } + resp.Truncate(maxSize) + } + if err := w.WriteMsg(resp); err != nil { + h.Logger.Errorf("Failed to write forwarded DNS response: %v", err) + } return } h.Logger.Errorf("Upstream query error to %s: %v", target, err) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 4cad20e..f78eea0 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -3,32 +3,11 @@ package logger import ( "io" "os" - "sync" "github.com/muesli/termenv" "github.com/rs/zerolog" ) -var loggerBytePool = &byteSlicePool{ - pool: sync.Pool{ - New: func() any { - return make([]byte, 8*1024) - }, - }, -} - -type byteSlicePool struct { - pool sync.Pool -} - -func (b *byteSlicePool) Get() []byte { - return b.pool.Get().([]byte) -} - -func (b *byteSlicePool) Put(buf []byte) { - b.pool.Put(buf) -} - // Fields is a map of string keys to arbitrary values, emulating logrus.Fields // for compatibility with existing code. type Fields map[string]any @@ -167,57 +146,43 @@ func NewSystemLogger(name string) (*Logger, error) { return NewPluginLogger("system", name) } -// Writer returns an io.WriteCloser that logs each written line. +// Writer returns an io.WriteCloser that logs each complete line written to it. +// +// It writes directly and synchronously, with no background goroutine or pipe. +// The previous implementation spawned a goroutine blocked on an io.Pipe read; +// because os/exec never closes a user-supplied writer, that goroutine (and its +// pooled buffer) leaked for every spawned child process. func (l *Logger) Writer() io.WriteCloser { - pr, pw := io.Pipe() - - go func() { - defer pr.Close() - buf := loggerBytePool.Get() - defer loggerBytePool.Put(buf) - - var tmp []byte - - for { - n, err := pr.Read(buf) - if n > 0 { - tmp = append(tmp, buf[:n]...) - for { - idx := indexOfNewline(tmp) - if idx == -1 { - break - } - line := tmp[:idx] - line = trimCR(line) - l.Info(string(line)) - tmp = tmp[idx+1:] - } - } - if err != nil { - // Exit on error or EOF - break - } - } - // Logging any remaining data - if len(tmp) > 0 { - l.Info(string(tmp)) - } - }() - - return &pipeWriteCloser{pipeWriter: pw} + return &lineWriter{l: l} } -// pipeWriteCloser implements Write and Close delegating to a PipeWriter. -type pipeWriteCloser struct { - pipeWriter *io.PipeWriter +// lineWriter accumulates bytes and emits one log line per newline. Each stream +// (stdout/stderr) gets its own instance and is written by a single os/exec copy +// goroutine, so no internal locking is required. +type lineWriter struct { + l *Logger + buf []byte } -func (pwc *pipeWriteCloser) Write(data []byte) (int, error) { - return pwc.pipeWriter.Write(data) +func (lw *lineWriter) Write(p []byte) (int, error) { + lw.buf = append(lw.buf, p...) + for { + idx := indexOfNewline(lw.buf) + if idx == -1 { + break + } + lw.l.Info(string(trimCR(lw.buf[:idx]))) + lw.buf = lw.buf[idx+1:] + } + return len(p), nil } -func (pwc *pipeWriteCloser) Close() error { - return pwc.pipeWriter.Close() +func (lw *lineWriter) Close() error { + if len(lw.buf) > 0 { + lw.l.Info(string(trimCR(lw.buf))) + lw.buf = nil + } + return nil } func indexOfNewline(buf []byte) int { diff --git a/internal/logger/retention.go b/internal/logger/retention.go new file mode 100644 index 0000000..7125ec7 --- /dev/null +++ b/internal/logger/retention.go @@ -0,0 +1,46 @@ +package logger + +import ( + "os" + "path/filepath" + "time" + + "github.com/mirkobrombin/goup/internal/config" +) + +// StartRetention launches a background sweeper that deletes log files older than +// retentionDays. It is a no-op when retentionDays <= 0 (keep logs forever). +func StartRetention(retentionDays int) { + if retentionDays <= 0 { + return + } + go func() { + // Run once at startup, then daily. + purgeOldLogs(retentionDays) + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for range ticker.C { + purgeOldLogs(retentionDays) + } + }() +} + +func purgeOldLogs(retentionDays int) { + root := config.GetLogDir() + cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour) + + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + // Only prune log and heap-dump artifacts, and only when stale. + ext := filepath.Ext(path) + if ext != ".log" && ext != ".pprof" { + return nil + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(path) + } + return nil + }) +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 0bf49e6..41a0837 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -116,6 +116,22 @@ func (pm *PluginManager) InitPluginsForSite(conf config.SiteConfig, l *logger.Lo return nil } +// ExitPlugins calls OnExit on all registered plugins. It is invoked during +// graceful shutdown and before a restart re-execs the process, so plugins that +// spawn child processes (Node.js, Python, Docker) can terminate them instead of +// leaving orphans holding their ports. +func (pm *PluginManager) ExitPlugins() { + var plugins []Plugin + if snap := pm.snapshot.Load(); snap != nil { + plugins = *snap + } + for _, p := range plugins { + if err := p.OnExit(); err != nil { + fmt.Printf("Plugin %s OnExit error: %v\n", p.Name(), err) + } + } +} + // GetRegisteredPlugins returns the names of all registered plugins. func (pm *PluginManager) GetRegisteredPlugins() []string { pm.mu.Lock() diff --git a/internal/restart/restart.go b/internal/restart/restart.go index 7356345..83d2dad 100644 --- a/internal/restart/restart.go +++ b/internal/restart/restart.go @@ -16,6 +16,15 @@ var srv *http.Server // single-server srv, which only ever tracked the last-registered instance. var shutdownFn func(time.Duration) error +// exitFn, when set, terminates plugin-spawned child processes. It is used on +// the non-graceful ForceRestart path (SafeGuard), since that skips shutdownFn. +var exitFn func() + +// SetExitFunc registers a routine that terminates plugin child processes. +func SetExitFunc(fn func()) { + exitFn = fn +} + // SetServer sets the server instance to be restarted. func SetServer(s *http.Server) { srv = s @@ -68,6 +77,9 @@ func RestartServer() { // ForceRestart restarts the server immediately without waiting for graceful shutdown. func ForceRestart() { + if exitFn != nil { + exitFn() + } exe, err := os.Executable() if err != nil { log.Fatalf("Failed to get executable: %v", err) diff --git a/internal/server/handler.go b/internal/server/handler.go index 39e1c8d..87298ba 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -24,7 +24,18 @@ func createHandler(conf config.SiteConfig, log *logger.Logger, identifier string // header names on every request. exposeHeaders := joinHeaderNames(conf.CustomHeaders) - if conf.ProxyPass != "" { + if len(conf.ProxyUpstreams) > 0 { + // Load-balance across multiple upstreams with passive health checks. + lb, err := newLoadBalancer(conf.ProxyUpstreams, log) + if err != nil { + return nil, fmt.Errorf("invalid proxy_upstreams: %v", err) + } + handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addCustomHeaders(w, conf.CustomHeaders, exposeHeaders) + lb.ServeHTTP(w, r) + }) + + } else if conf.ProxyPass != "" { // Set up reverse proxy handler if ProxyPass is set. proxy, err := getSharedReverseProxy(conf, log) if err != nil { @@ -38,8 +49,12 @@ func createHandler(conf config.SiteConfig, log *logger.Logger, identifier string } else { // Static File Handler with custom design and directory listing + cacheControl := conf.CacheControl handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { addCustomHeaders(w, conf.CustomHeaders, exposeHeaders) + if cacheControl != "" { + w.Header().Set("Cache-Control", cacheControl) + } ServeStatic(w, r, conf.RootDirectory) }) } @@ -65,6 +80,30 @@ func createHandler(conf config.SiteConfig, log *logger.Logger, identifier string // Apply the final chain of middleware handler = siteMwManager.Apply(handler) + // Edge middleware wraps the entire chain (outermost first). Applied in + // reverse so ForceHTTPS ends up outermost, then IP filtering, rate limiting, + // body limit, CORS, and finally security headers closest to the site chain. + if conf.SecurityHeaders || conf.HSTS { + handler = middleware.SecurityHeadersMiddleware(conf.HSTS, conf.HSTSMaxAge, conf.SecurityHeaders)(handler) + } + if conf.CORS != nil { + handler = middleware.CORSMiddleware(conf.CORS)(handler) + } + // Body limit is opt-in: a global default would break large uploads streamed + // to proxied/plugin backends. Enforce only when the site configures it. + if conf.MaxBodyBytes > 0 { + handler = middleware.BodyLimitMiddleware(conf.MaxBodyBytes)(handler) + } + if conf.RateLimitRPS > 0 { + handler = middleware.RateLimitMiddleware(conf.RateLimitRPS, conf.RateLimitBurst)(handler) + } + if len(conf.AllowIPs) > 0 || len(conf.DenyIPs) > 0 { + handler = middleware.IPFilterMiddleware(conf.AllowIPs, conf.DenyIPs)(handler) + } + if conf.ForceHTTPS { + handler = middleware.ForceHTTPSMiddleware()(handler) + } + return handler, nil } @@ -132,6 +171,7 @@ func (b *byteSlicePool) Put(buf []byte) { // a custom buffer_size_kb actually reuses its buffers instead of allocating // a fresh one on every request. if cap(buf) >= b.size { + //lint:ignore SA6002 httputil.BufferPool mandates a []byte pool. b.pool.Put(buf[:b.size]) } } diff --git a/internal/server/lifecycle.go b/internal/server/lifecycle.go index 7a7e04f..ba89345 100644 --- a/internal/server/lifecycle.go +++ b/internal/server/lifecycle.go @@ -9,6 +9,8 @@ import ( "sync" "sync/atomic" "time" + + "github.com/mirkobrombin/goup/internal/plugin" ) const DefaultShutdownTimeout = 10 * time.Second @@ -107,6 +109,10 @@ func ShutdownServers(timeout time.Duration) error { wg.Wait() close(errs) + // Terminate plugin-spawned child processes (Node.js, Python, Docker) so + // they do not outlive the server as orphans holding their ports. + plugin.GetPluginManagerInstance().ExitPlugins() + var shutdownErr error for err := range errs { shutdownErr = errors.Join(shutdownErr, err) diff --git a/internal/server/loadbalancer.go b/internal/server/loadbalancer.go new file mode 100644 index 0000000..3e76807 --- /dev/null +++ b/internal/server/loadbalancer.go @@ -0,0 +1,124 @@ +package server + +import ( + "context" + "net/http" + "net/http/httputil" + "net/url" + "sync/atomic" + "time" + + "github.com/mirkobrombin/goup/internal/assets" + "github.com/mirkobrombin/goup/internal/logger" +) + +// lbFailKeyType is the context key used to signal a retryable transport failure +// from a backend's ErrorHandler back to the load balancer. +type lbFailKeyType struct{} + +var lbFailKey = lbFailKeyType{} + +// lbCooldown is how long a backend stays ejected after a failure before it is +// passively retried. +const lbCooldown = 30 * time.Second + +type lbBackend struct { + target *url.URL + proxy *httputil.ReverseProxy + down atomic.Bool + downAt atomic.Int64 // unix nanos when marked down +} + +func (b *lbBackend) isDown() bool { + if !b.down.Load() { + return false + } + if time.Since(time.Unix(0, b.downAt.Load())) > lbCooldown { + // Cooldown elapsed: give the backend another chance. + b.down.Store(false) + return false + } + return true +} + +func (b *lbBackend) markDown() { + b.downAt.Store(time.Now().UnixNano()) + b.down.Store(true) +} + +// loadBalancer round-robins requests across healthy backends, ejecting a backend +// on a connection failure and retrying the next one, as long as nothing has been +// written to the client yet. +type loadBalancer struct { + backends []*lbBackend + counter atomic.Uint64 + log *logger.Logger +} + +// trackWriter records whether any part of the response has been committed, so +// the balancer knows when it is still safe to retry another backend. +type trackWriter struct { + http.ResponseWriter + wrote bool +} + +func (t *trackWriter) WriteHeader(code int) { + t.wrote = true + t.ResponseWriter.WriteHeader(code) +} + +func (t *trackWriter) Write(b []byte) (int, error) { + t.wrote = true + return t.ResponseWriter.Write(b) +} + +func newLoadBalancer(targets []string, log *logger.Logger) (*loadBalancer, error) { + lb := &loadBalancer{log: log} + for _, t := range targets { + u, err := url.Parse(t) + if err != nil { + return nil, err + } + proxy := httputil.NewSingleHostReverseProxy(u) + proxy.Transport = defaultTransport + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + if f, ok := r.Context().Value(lbFailKey).(*bool); ok { + *f = true + return // let the balancer retry another backend + } + assets.RenderErrorPage(w, http.StatusBadGateway, "Bad Gateway", "Unable to reach the backend server.") + } + lb.backends = append(lb.backends, &lbBackend{target: u, proxy: proxy}) + } + return lb, nil +} + +func (lb *loadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + n := len(lb.backends) + start := int(lb.counter.Add(1) % uint64(n)) + + for i := 0; i < n; i++ { + b := lb.backends[(start+i)%n] + if b.isDown() { + continue + } + + failed := false + ctx := context.WithValue(r.Context(), lbFailKey, &failed) + tw := &trackWriter{ResponseWriter: w} + b.proxy.ServeHTTP(tw, r.WithContext(ctx)) + + if !failed { + return // success + } + b.markDown() + lb.log.Errorf("Upstream %s failed, ejecting for %s", b.target, lbCooldown) + if tw.wrote { + // Response already partially sent; cannot safely retry. + return + } + } + + // Every backend was down or failed. + assets.RenderErrorPage(w, http.StatusBadGateway, "Bad Gateway", "No healthy backend available.") +} diff --git a/internal/server/middleware/edge.go b/internal/server/middleware/edge.go new file mode 100644 index 0000000..a7121df --- /dev/null +++ b/internal/server/middleware/edge.go @@ -0,0 +1,183 @@ +package middleware + +import ( + "fmt" + "net" + "net/http" + "strconv" + "strings" + + "github.com/mirkobrombin/goup/internal/config" +) + +// parseCIDRs turns a list of CIDRs or bare IPs into networks. A bare IP becomes +// a host route (/32 or /128). +func parseCIDRs(list []string) []*net.IPNet { + var nets []*net.IPNet + for _, item := range list { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if !strings.Contains(item, "/") { + if ip := net.ParseIP(item); ip != nil { + if ip.To4() != nil { + item += "/32" + } else { + item += "/128" + } + } + } + if _, n, err := net.ParseCIDR(item); err == nil { + nets = append(nets, n) + } + } + return nets +} + +func ipInNets(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} + +// IPFilterMiddleware allows or denies requests by client IP. If allow is +// non-empty, only clients within it may connect; deny always wins. The client +// IP is taken from RemoteAddr (the real peer), never from spoofable headers. +func IPFilterMiddleware(allow, deny []string) MiddlewareFunc { + allowNets := parseCIDRs(allow) + denyNets := parseCIDRs(deny) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if len(denyNets) > 0 && ipInNets(ip, denyNets) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if len(allowNets) > 0 && !ipInNets(ip, allowNets) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// ForceHTTPSMiddleware redirects plain-HTTP requests to their HTTPS equivalent +// (301). Put a site's HTTP listener behind this and its HTTPS listener behind +// the real handler. +func ForceHTTPSMiddleware() MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusMovedPermanently) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// BodyLimitMiddleware caps the request body size to guard against memory +// exhaustion. max <= 0 means unlimited. +func BodyLimitMiddleware(max int64) MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if max > 0 && r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, max) + } + next.ServeHTTP(w, r) + }) + } +} + +// SecurityHeadersMiddleware sets HSTS (only over TLS) and a set of conservative +// security headers when enabled for the site. +func SecurityHeadersMiddleware(hsts bool, hstsMaxAge int, extra bool) MiddlewareFunc { + if hstsMaxAge <= 0 { + hstsMaxAge = 31536000 + } + hstsValue := "max-age=" + strconv.Itoa(hstsMaxAge) + "; includeSubDomains" + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + if hsts && r.TLS != nil { + h.Set("Strict-Transport-Security", hstsValue) + } + if extra { + h.Set("X-Content-Type-Options", "nosniff") + if h.Get("X-Frame-Options") == "" { + h.Set("X-Frame-Options", "SAMEORIGIN") + } + if h.Get("Referrer-Policy") == "" { + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + } + } + next.ServeHTTP(w, r) + }) + } +} + +// CORSMiddleware adds CORS headers and short-circuits preflight OPTIONS +// requests according to the site's configuration. +func CORSMiddleware(cfg *config.CORSConfig) MiddlewareFunc { + allowAll := false + origins := make(map[string]bool) + for _, o := range cfg.AllowedOrigins { + if o == "*" { + allowAll = true + } + origins[o] = true + } + methods := "GET, POST, PUT, DELETE, PATCH, OPTIONS" + if len(cfg.AllowedMethods) > 0 { + methods = strings.Join(cfg.AllowedMethods, ", ") + } + headers := "Content-Type, Authorization" + if len(cfg.AllowedHeaders) > 0 { + headers = strings.Join(cfg.AllowedHeaders, ", ") + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (allowAll || origins[origin]) { + h := w.Header() + if allowAll && !cfg.AllowCredentials { + h.Set("Access-Control-Allow-Origin", "*") + } else { + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + } + if cfg.AllowCredentials { + h.Set("Access-Control-Allow-Credentials", "true") + } + if r.Method == http.MethodOptions { + h.Set("Access-Control-Allow-Methods", methods) + h.Set("Access-Control-Allow-Headers", headers) + if cfg.MaxAge > 0 { + h.Set("Access-Control-Max-Age", fmt.Sprintf("%d", cfg.MaxAge)) + } + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/middleware/edge_test.go b/internal/server/middleware/edge_test.go new file mode 100644 index 0000000..e9c7b2d --- /dev/null +++ b/internal/server/middleware/edge_test.go @@ -0,0 +1,92 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func TestIPFilterMiddleware(t *testing.T) { + // Deny loopback. + h := IPFilterMiddleware(nil, []string{"127.0.0.0/8"})(okHandler()) + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("denied IP: expected 403, got %d", rec.Code) + } + + // Allowlist that does not include the client. + h = IPFilterMiddleware([]string{"10.0.0.0/8"}, nil)(okHandler()) + req = httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("non-allowlisted IP: expected 403, got %d", rec.Code) + } + + // Allowlist that includes the client. + h = IPFilterMiddleware([]string{"127.0.0.1"}, nil)(okHandler()) + req = httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("allowlisted IP: expected 200, got %d", rec.Code) + } +} + +func TestRateLimitMiddleware(t *testing.T) { + h := RateLimitMiddleware(1, 2)(okHandler()) + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "203.0.113.5:9999" + + codes := make([]int, 4) + for i := range codes { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + codes[i] = rec.Code + } + // Burst of 2 allowed, then limited. + if codes[0] != 200 || codes[1] != 200 { + t.Errorf("first two requests should pass, got %v", codes) + } + if codes[2] != http.StatusTooManyRequests { + t.Errorf("third request should be 429, got %v", codes) + } +} + +func TestBodyLimitMiddleware(t *testing.T) { + echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 1024) + n := 0 + for { + m, err := r.Body.Read(buf) + n += m + if err != nil { + if err.Error() == "http: request body too large" { + http.Error(w, "too big", http.StatusRequestEntityTooLarge) + return + } + break + } + } + w.WriteHeader(http.StatusOK) + }) + h := BodyLimitMiddleware(10)(echo) + req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("x", 100))) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("oversized body: expected 413, got %d", rec.Code) + } +} diff --git a/internal/server/middleware/gzip.go b/internal/server/middleware/gzip.go index 2a7353a..17d4011 100644 --- a/internal/server/middleware/gzip.go +++ b/internal/server/middleware/gzip.go @@ -45,6 +45,15 @@ func GzipMiddleware(next http.Handler) http.Handler { return } + // A previous gzip response advertised a weak "...-gzip" ETag. Strip that + // suffix from the inbound If-None-Match so http.ServeContent can match it + // against the handler's identity ETag and return 304 instead of a full + // re-compressed 200. Without this, revalidation of on-the-fly gzipped + // content never hits. + if inm := r.Header.Get("If-None-Match"); strings.Contains(inm, "-gzip\"") { + r.Header.Set("If-None-Match", strings.ReplaceAll(inm, "-gzip\"", "\"")) + } + // Optimized Wrapper gw := &smartGzipWriter{ ResponseWriter: w, diff --git a/internal/server/middleware/ratelimit.go b/internal/server/middleware/ratelimit.go new file mode 100644 index 0000000..4721876 --- /dev/null +++ b/internal/server/middleware/ratelimit.go @@ -0,0 +1,93 @@ +package middleware + +import ( + "net" + "net/http" + "sync" + "time" +) + +// rateLimiter is a per-client-IP token bucket. Stale buckets are evicted by a +// background sweeper so memory does not grow unbounded with unique clients. +type rateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + rate float64 // tokens per second + burst float64 +} + +type bucket struct { + tokens float64 + last time.Time +} + +func newRateLimiter(rps float64, burst int) *rateLimiter { + if burst < 1 { + burst = 1 + } + rl := &rateLimiter{ + buckets: make(map[string]*bucket), + rate: rps, + burst: float64(burst), + } + go rl.sweep() + return rl +} + +func (rl *rateLimiter) allow(ip string) bool { + now := time.Now() + rl.mu.Lock() + defer rl.mu.Unlock() + + b := rl.buckets[ip] + if b == nil { + b = &bucket{tokens: rl.burst, last: now} + rl.buckets[ip] = b + } + b.tokens += now.Sub(b.last).Seconds() * rl.rate + if b.tokens > rl.burst { + b.tokens = rl.burst + } + b.last = now + if b.tokens >= 1 { + b.tokens-- + return true + } + return false +} + +func (rl *rateLimiter) sweep() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-10 * time.Minute) + rl.mu.Lock() + for ip, b := range rl.buckets { + if b.last.Before(cutoff) { + delete(rl.buckets, ip) + } + } + rl.mu.Unlock() + } +} + +// RateLimitMiddleware limits requests per client IP to rps (with the given +// burst). It keys on the direct connection address (RemoteAddr), which is not +// spoofable, unlike X-Forwarded-For. +func RateLimitMiddleware(rps float64, burst int) MiddlewareFunc { + rl := newRateLimiter(rps, burst) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + if !rl.allow(ip) { + w.Header().Set("Retry-After", "1") + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 99280e9..eccb453 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,7 +1,6 @@ package server import ( - "crypto/tls" "fmt" "net" "net/http" @@ -45,6 +44,15 @@ func StartServers(configs []config.SiteConfig, enableTUI bool, enableBench bool, // Initialize the global async logger middleware.InitAsyncLogger(10000) + // Start the log retention sweeper (no-op when not configured). + config.GlobalConfMu.RLock() + retention := 0 + if config.GlobalConf != nil { + retention = config.GlobalConf.LogRetentionDays + } + config.GlobalConfMu.RUnlock() + logger.StartRetention(retention) + var wg sync.WaitGroup // Start DNS Server if requested (and available) @@ -98,6 +106,7 @@ func startSingleServer(conf config.SiteConfig, mwManager *middleware.MiddlewareM } server := createHTTPServer(conf, handler) + conf.SSL.Enabled = setupTLS(server, []config.SiteConfig{conf}, lg) restart.SetServer(server) startServerInstance(server, conf, lg) } @@ -134,32 +143,7 @@ func startVirtualHostServer(port int, configs []config.SiteConfig, mwManager *mi radixTree.Insert(conf.Domain, handler) } - // Load one certificate per SSL-enabled domain on this port. The tls - // package selects the right certificate by SNI at handshake time, so - // virtual hosts no longer silently lose TLS (which previously served them - // as plaintext HTTP on 443). - var certs []tls.Certificate - sslCount := 0 - for _, conf := range configs { - if !conf.SSL.Enabled { - continue - } - sslCount++ - cert, err := tls.LoadX509KeyPair(conf.SSL.Certificate, conf.SSL.Key) - if err != nil { - lg.Errorf("SSL certificate error for %s: %v", conf.Domain, err) - continue - } - certs = append(certs, cert) - } - if sslCount > 0 && sslCount != len(configs) { - lg.Errorf("Port %d mixes SSL and non-SSL sites; all will be served over TLS", port) - } - serverConf := config.SiteConfig{Port: port} - if len(certs) > 0 { - serverConf.SSL.Enabled = true - } mainHandler := func(w_ http.ResponseWriter, r_ *http.Request) { host, _, err := net.SplitHostPort(r_.Host) @@ -180,8 +164,6 @@ func startVirtualHostServer(port int, configs []config.SiteConfig, mwManager *mi } server := createHTTPServer(serverConf, http.HandlerFunc(mainHandler)) - if len(certs) > 0 { - server.TLSConfig.Certificates = certs - } + serverConf.SSL.Enabled = setupTLS(server, configs, lg) startServerInstance(server, serverConf, lg) } diff --git a/internal/server/server_utils.go b/internal/server/server_utils.go index 1866b0e..369a33f 100644 --- a/internal/server/server_utils.go +++ b/internal/server/server_utils.go @@ -73,20 +73,10 @@ func startServerInstance(server *http.Server, conf config.SiteConfig, l *logger. go func() { if conf.SSL.Enabled { - // For a virtual-host server the caller pre-loads one certificate - // per domain into TLSConfig.Certificates; the tls package selects - // the right one by SNI. For a single-site server we load its - // keypair here. Either way the same certificates are shared with + // server.TLSConfig has already been populated by setupTLS with + // either static certificates (SNI-selected) or an ACME + // GetCertificate callback. Share the same certificate source with // the QUIC (h3) server. - if len(server.TLSConfig.Certificates) == 0 { - cert, err := tls.LoadX509KeyPair(conf.SSL.Certificate, conf.SSL.Key) - if err != nil { - l.Errorf("SSL certificate error for %s: %v", conf.Domain, err) - return - } - server.TLSConfig.Certificates = []tls.Certificate{cert} - } - l.Infof("Serving %s on HTTPS port %d with HTTP/2 and HTTP/3 support", conf.Domain, conf.Port) h3 := &http3.Server{ @@ -94,8 +84,9 @@ func startServerInstance(server *http.Server, conf config.SiteConfig, l *logger. Port: conf.Port, Handler: server.Handler, TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - Certificates: server.TLSConfig.Certificates, + MinVersion: tls.VersionTLS12, + Certificates: server.TLSConfig.Certificates, + GetCertificate: server.TLSConfig.GetCertificate, }, } registerCloser(h3) diff --git a/internal/server/server_web.go b/internal/server/server_web.go index dcd8e93..8f88657 100644 --- a/internal/server/server_web.go +++ b/internal/server/server_web.go @@ -94,9 +94,11 @@ func launchWebComponents(configs []config.SiteConfig, enableTUI bool, enableBenc } } - // Make restart drain every registered server, and let SafeGuard watch - // memory once everything is wired up. + // Make restart drain every registered server, terminate plugin child + // processes on the force-restart path, and let SafeGuard watch memory once + // everything is wired up. restart.SetShutdownFunc(ShutdownServers) + restart.SetExitFunc(pluginManager.ExitPlugins) safeguard.Start() // Start servers diff --git a/internal/server/tls.go b/internal/server/tls.go new file mode 100644 index 0000000..ea755c2 --- /dev/null +++ b/internal/server/tls.go @@ -0,0 +1,90 @@ +package server + +import ( + "crypto/tls" + "net/http" + + "github.com/mirkobrombin/goup/internal/config" + "github.com/mirkobrombin/goup/internal/logger" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" +) + +// setupTLS configures server.TLSConfig for the given site configs (one for a +// single-site server, many for a virtual host on the same port). It loads +// static certificates and, when any site requests ACME, wires an autocert +// manager that obtains and renews Let's Encrypt certificates (via TLS-ALPN-01 +// on the TLS port). It returns false when no site enables TLS. +func setupTLS(server *http.Server, confs []config.SiteConfig, lg *logger.Logger) bool { + var staticCerts []tls.Certificate + var acmeHosts []string + var acmeEmail, acmeCache string + tlsWanted := false + + for _, c := range confs { + if !c.SSL.Enabled { + continue + } + tlsWanted = true + if c.SSL.ACME { + acmeHosts = append(acmeHosts, c.Domain) + if c.SSL.Email != "" { + acmeEmail = c.SSL.Email + } + if c.SSL.CacheDir != "" { + acmeCache = c.SSL.CacheDir + } + continue + } + if c.SSL.Certificate == "" || c.SSL.Key == "" { + lg.Errorf("SSL enabled for %s but certificate/key not set (and ACME off)", c.Domain) + continue + } + cert, err := tls.LoadX509KeyPair(c.SSL.Certificate, c.SSL.Key) + if err != nil { + lg.Errorf("SSL certificate error for %s: %v", c.Domain, err) + continue + } + staticCerts = append(staticCerts, cert) + } + + if !tlsWanted { + return false + } + + server.TLSConfig.MinVersion = tls.VersionTLS12 + + if len(acmeHosts) == 0 { + // Static certificates only; the tls package selects by SNI. + server.TLSConfig.Certificates = staticCerts + return true + } + + cacheDir := acmeCache + if cacheDir == "" { + cacheDir = config.GetACMEDir() + } + mgr := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(acmeHosts...), + Cache: autocert.DirCache(cacheDir), + Email: acmeEmail, + } + lg.Infof("ACME auto-TLS enabled for %v (cache %s)", acmeHosts, cacheDir) + + // TLS-ALPN-01 needs the acme protocol advertised in ALPN. + server.TLSConfig.NextProtos = []string{"h2", "http/1.1", acme.ALPNProto} + + // Prefer a matching static certificate (for non-ACME hosts sharing the + // port), otherwise let the ACME manager serve/obtain one. + certs := staticCerts + server.TLSConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + for i := range certs { + if err := hello.SupportsCertificate(&certs[i]); err == nil { + return &certs[i], nil + } + } + return mgr.GetCertificate(hello) + } + return true +} diff --git a/plugins/docker_base.go b/plugins/docker_base.go index 2b257e0..7047ac9 100644 --- a/plugins/docker_base.go +++ b/plugins/docker_base.go @@ -188,7 +188,15 @@ func (d *DockerBasePlugin) callDockerAPI(cfg DockerBaseConfig, method, path stri } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) - return string(data), err + if err != nil { + return "", err + } + // Treat HTTP error responses as failures so listContainers falls back to the + // CLI instead of surfacing an error body as if it were the container list. + if resp.StatusCode >= 400 { + return "", fmt.Errorf("docker API %s %s returned status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(data))) + } + return string(data), nil } // RunDockerCLI executes a Docker/Podman CLI command. diff --git a/plugins/nodejs.go b/plugins/nodejs.go index 7c11767..9a9cb38 100644 --- a/plugins/nodejs.go +++ b/plugins/nodejs.go @@ -173,7 +173,6 @@ func (p *NodeJSPlugin) ensureNodeServerRunning(domain string, cfg NodeJSPluginCo go func(dom string, c *exec.Cmd) { err := c.Wait() p.PluginLogger.Infof("Node.js server exited for domain=%s (PID=%d), err=%v", dom, c.Process.Pid, err) - p.PluginLogger.Writer().Close() p.mu.Lock() p.processes[dom] = nil diff --git a/plugins/proxy_shared.go b/plugins/proxy_shared.go index a40a535..e9e09d7 100644 --- a/plugins/proxy_shared.go +++ b/plugins/proxy_shared.go @@ -37,7 +37,9 @@ var upstreamProxies sync.Map // target URL -> *httputil.ReverseProxy // small responses do not pay a fresh 32KB allocation per request. type upstreamBufferPool struct{ pool sync.Pool } -func (p *upstreamBufferPool) Get() []byte { return p.pool.Get().([]byte) } +func (p *upstreamBufferPool) Get() []byte { return p.pool.Get().([]byte) } + +//lint:ignore SA6002 httputil.BufferPool mandates a []byte pool. func (p *upstreamBufferPool) Put(b []byte) { p.pool.Put(b) } var sharedBufferPool = &upstreamBufferPool{ diff --git a/plugins/python.go b/plugins/python.go index cd20468..d858f09 100644 --- a/plugins/python.go +++ b/plugins/python.go @@ -218,7 +218,6 @@ func (p *PythonPlugin) ensurePythonProcess(domain string) { go func(dom string, c *exec.Cmd) { err := c.Wait() p.PluginLogger.Infof("Python server exited for domain '%s' (PID=%d), err=%v", dom, c.Process.Pid, err) - p.PluginLogger.Writer().Close() p.mu.Lock() st.process = nil p.mu.Unlock()