From ed2ef0dcf0b5221a3fb29774d17709f8998ff452 Mon Sep 17 00:00:00 2001 From: Francis Date: Mon, 4 May 2026 20:44:53 -0400 Subject: [PATCH] init --- .github/workflows/ci.yml | 35 +++++++-------- .github/workflows/cleanup-deployments.yml | 9 +++- .github/workflows/release.yml | 15 ++++--- Dockerfile | 37 ++++++++-------- Makefile | 9 ++-- README.md | 14 +++--- go.mod | 2 +- internal/config/config.go | 47 ++++++++++++++++++++ internal/config/config_test.go | 46 ++++++++++++++++++++ internal/observability/metrics.go | 53 ++++++++++++++++++----- internal/observability/metrics_test.go | 48 ++++++++++++++++++++ internal/smtp/server.go | 5 ++- internal/smtp/server_test.go | 4 +- start.sh | 16 ++----- 14 files changed, 262 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7ada37..d5d9fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: write-all +permissions: + contents: read jobs: lint: @@ -18,33 +19,33 @@ jobs: runs-on: ubuntu-latest steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version-file: go.mod cache: true - name: πŸ•΅οΈ Install golangci-lint - uses: golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: args: --timeout=5m - name: πŸ›‘οΈ Run govulncheck run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./... + GOTOOLCHAIN=go$(awk '/^go /{print $2; exit}' go.mod) go install golang.org/x/vuln/cmd/govulncheck@latest + GOTOOLCHAIN=go$(awk '/^go /{print $2; exit}' go.mod) govulncheck ./... deps: name: πŸ“¦ Dependencies runs-on: ubuntu-latest steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version-file: go.mod cache: true @@ -64,10 +65,10 @@ jobs: runs-on: ubuntu-latest steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version-file: go.mod cache: true @@ -96,10 +97,10 @@ jobs: go: ${{ (github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'release-run')) && fromJSON('["stable"]') || fromJSON('["stable", "oldstable"]') }} steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version: ${{ matrix.go }} cache: true @@ -117,10 +118,10 @@ jobs: runs-on: ubuntu-latest steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version-file: go.mod cache: true @@ -129,7 +130,7 @@ jobs: run: go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./... - name: βœ… Enforce coverage threshold - uses: vladopajic/go-test-coverage@v2 + uses: vladopajic/go-test-coverage@a2cbf1fbcac20b3a5d09badb628331fa7bded52c # v2.14.0 with: profile: cover.out config: ./.github/.testcoverage.yml @@ -149,10 +150,10 @@ jobs: go: ${{ (github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'release-run')) && fromJSON('["stable"]') || fromJSON('["stable", "oldstable"]') }} steps: - name: πŸ“₯ Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: πŸ”§ Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version: ${{ matrix.go }} cache: true diff --git a/.github/workflows/cleanup-deployments.yml b/.github/workflows/cleanup-deployments.yml index b48148d..348e7dd 100644 --- a/.github/workflows/cleanup-deployments.yml +++ b/.github/workflows/cleanup-deployments.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Cleanup GitHub deployments env: @@ -44,6 +44,11 @@ jobs: run: | set -euo pipefail + if ! printf '%s' "${KEEP}" | grep -Eq '^[0-9]+$'; then + echo "keep_latest must be a non-negative integer" + exit 1 + fi + # Ensure jq is available if ! command -v jq >/dev/null 2>&1; then sudo apt-get update -y @@ -85,7 +90,7 @@ jobs: echo "Found $total deployment(s)${ENV_FILTER:+ for environment '${ENV_FILTER}'}" # Determine which to delete (skip the first KEEP entries) - to_delete=$(echo "$deployments_sorted" | jq ".[${KEEP}:]") + to_delete=$(echo "$deployments_sorted" | jq --argjson skip "$KEEP" '.[$skip:]') del_count=$(echo "$to_delete" | jq 'length') echo "Keeping ${KEEP}, candidate deletions: $del_count" if [ "$del_count" -eq 0 ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78a7d2f..0455ca4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,10 @@ jobs: goarch: 386 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 with: go-version-file: go.mod cache: true @@ -42,7 +42,7 @@ jobs: go build -trimpath -ldflags "-s -w" -o "dist/${BIN_NAME}-${{ matrix.goos }}-${{ matrix.goarch }}" ./ - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: inboundparse-${{ matrix.goos }}-${{ matrix.goarch }} path: dist/* @@ -52,17 +52,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: dist - name: Assemble assets run: | mkdir -p assets - find dist -type f -maxdepth 2 -print -exec bash -lc 'f="{}"; base=$(basename "$f"); cp "$f" "assets/$base"' \; + find dist -type f -maxdepth 2 -print0 | while IFS= read -r -d '' f; do + base=$(basename "$f") + cp "$f" "assets/$base" + done - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 with: files: assets/* env: diff --git a/Dockerfile b/Dockerfile index 5c6f3f2..a0190bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.26.1-alpine AS builder +FROM golang:1.26.2-alpine3.21 AS builder WORKDIR /app @@ -16,27 +16,30 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o inboundparse ./cmd/inboundparse # Final stage -FROM alpine:latest +FROM alpine:3.21 -# Install ca-certificates, openssl, and curl for HTTPS webhook calls and certificate management -RUN apk --no-cache add ca-certificates openssl curl socat +ARG ACME_SH_VERSION=3.0.7 -# Install acme.sh -RUN curl https://get.acme.sh | sh -s email=${ACME_SH_EMAIL:-dev@inboundparse.com} +# Install ca-certificates, openssl, socat, git, bash; pin acme.sh via git tag (no curl|sh) +RUN apk add --no-cache ca-certificates openssl socat git bash curl \ + && git clone --depth 1 --branch "${ACME_SH_VERSION}" https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \ + && addgroup -S inboundparse \ + && adduser -S -G inboundparse -h /home/inboundparse inboundparse \ + && mkdir -p /cert /home/inboundparse \ + && chown -R inboundparse:inboundparse /cert /home/inboundparse /opt/acme.sh \ + && ln -sf /opt/acme.sh/acme.sh /usr/local/bin/acme.sh \ + && rm -rf /opt/acme.sh/.git -WORKDIR /root/ +WORKDIR /home/inboundparse -# Copy the binary from builder stage -COPY --from=builder /app/inboundparse . +COPY --from=builder /app/inboundparse /usr/local/bin/inboundparse +COPY start.sh /usr/local/bin/start.sh -# Copy scripts -COPY start.sh ./start.sh +RUN chmod +x /usr/local/bin/inboundparse /usr/local/bin/start.sh \ + && chown inboundparse:inboundparse /usr/local/bin/inboundparse /usr/local/bin/start.sh -# Make scripts executable -RUN chmod +x ./start.sh - -# Create cert directory -RUN mkdir -p /cert +USER inboundparse +ENV HOME=/home/inboundparse # Environment variables for acme.sh and SMTP server ENV CF_Token="" @@ -61,4 +64,4 @@ EXPOSE 587 EXPOSE 9090 # Run the startup script -ENTRYPOINT ["./start.sh"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/start.sh"] diff --git a/Makefile b/Makefile index 0f2d262..008ade7 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,9 @@ BINARY_NAME=inboundparse DOCKER_IMAGE=inboundparse:latest GOBIN := $(shell go env GOPATH)/bin +# govulncheck must load packages with the same Go version as go.mod; otherwise +# the loader can fail (e.g. internal error: x/sys/unix "without types"). +GOTOOLCHAIN_GO := go$(shell awk '/^go /{print $$2; exit}' go.mod) help: @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}' @@ -68,9 +71,9 @@ lint: ## Lint (golangci-lint) + vuln scan (govulncheck) @echo "Running golangci-lint..." @command -v $(GOBIN)/golangci-lint >/dev/null 2>&1 || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest @$(GOBIN)/golangci-lint run --fix --timeout=5m - @echo "Running govulncheck..." - @command -v $(GOBIN)/govulncheck >/dev/null 2>&1 || go install golang.org/x/vuln/cmd/govulncheck@latest - @$(GOBIN)/govulncheck ./... + @echo "Running govulncheck ($(GOTOOLCHAIN_GO))..." + @GOTOOLCHAIN=$(GOTOOLCHAIN_GO) go install golang.org/x/vuln/cmd/govulncheck@latest + @GOTOOLCHAIN=$(GOTOOLCHAIN_GO) $(GOBIN)/govulncheck ./... fl: format lint vet ## Format, lint, and vet the code diff --git a/README.md b/README.md index eaac03c..18125a0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ A SMTP server that receives emails from any domain without authentication and fo > [!NOTE] > This is my first major Golang product, so I may not do things the **golang way**, but I am open to feedback. I have used this application privately for the last 6 months, and have received well over **100,000** of pieces of mail (mostly spam, but thats for another day). - ## Features ### Core SMTP Functionality @@ -17,6 +16,7 @@ A SMTP server that receives emails from any domain without authentication and fo - πŸ“ **Message Size Limits**: Configurable maximum message size (default: 10MB) ### Email Authentication (RFC Compliant) + - 🧐 **SPF Validation (RFC 7208)**: Validates Sender Policy Framework with HELO and envelope sender - πŸ–ŠοΈ **DKIM Validation (RFC 6376)**: Verifies DomainKeys Identified Mail signatures with multi-signature support - πŸ•΅οΈβ€β™‚οΈ **DMARC Validation (RFC 7489)**: Evaluates DMARC policy with hierarchical domain lookup and subdomain policy inheritance @@ -24,6 +24,7 @@ A SMTP server that receives emails from any domain without authentication and fo - πŸ” **Domain Hierarchy Tracking**: Tracks all attempted DMARC lookups with detailed per-domain results ### Observability & Monitoring + - πŸ“‹ **Structured Logging**: JSON-formatted logs with configurable levels - πŸ“Š **Prometheus Metrics**: Comprehensive metrics collection and monitoring - πŸ›ŽοΈ **Sentry Integration**: Error tracking and performance monitoring @@ -31,6 +32,7 @@ A SMTP server that receives emails from any domain without authentication and fo - πŸ“ˆ **Grafana Dashboards**: Pre-configured monitoring dashboards ### Deployment & Operations + - 🐳 **Docker Support**: Multi-stage Docker builds with Alpine Linux - 🧩 **Docker Compose**: Complete development stack with monitoring - πŸš€ **Fly.io Ready**: Pre-configured for Fly.io deployment (more to come!) @@ -38,6 +40,7 @@ A SMTP server that receives emails from any domain without authentication and fo - βš™οΈ **Environment Configuration**: Flexible configuration via environment variables ### Webhook Integration + - πŸ” **HTTP Basic Auth**: Secure webhook authentication - πŸ“¨ **Rich Payload**: Complete email data including headers and attachments - πŸ” **Reliability Features**: Automatic retry with exponential backoff, rate limiting, and circuit breaker @@ -102,6 +105,7 @@ make dev ``` With the dev stack running, you'll have: + - **InboundParse SMTP server** (`:25`, `:587`, `:9090`) - **Prometheus** for metrics (`:9091`) - **Grafana** dashboards (`:3000`, login `admin`/`admin`) @@ -146,9 +150,7 @@ Usage of ./inboundparse: -sentry-release string Sentry release version (optional) ``` - - -### πŸ”’ Environment Variables +### πŸ”’ Environment Variables You can configure InboundParse using environment variables or command line flags: @@ -265,7 +267,6 @@ The service sends a JSON payload to your webhook with comprehensive email data: } ``` - ## 🀝 Contributing Contributions of all kinds are welcome! See [CONTRIBUTE.md](./docs/CONTRIBUTE.md) for guidelines, and don’t hesitate to open issues, fork, or create PRs. @@ -273,4 +274,5 @@ Contributions of all kinds are welcome! See [CONTRIBUTE.md](./docs/CONTRIBUTE.md --- > **Need help or want to suggest new features?** -> Open an issue or start a discussion on GitHub! \ No newline at end of file +> Open an issue or start a discussion on GitHub! + diff --git a/go.mod b/go.mod index 4418393..a0e4328 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module inboundparse -go 1.26.1 +go 1.26.2 require ( github.com/emersion/go-msgauth v0.7.0 diff --git a/internal/config/config.go b/internal/config/config.go index d040aba..6120375 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,10 @@ import ( "flag" "fmt" "inboundparse/internal/utils" + "net" "net/url" "strconv" + "strings" "time" ) @@ -357,5 +359,50 @@ func (c *Config) Validate() error { } } + if c.EnableMetrics { + if err := validateMetricsAuth(c.MetricsAddr, c.MetricsAPIKey, c.MetricsUsername, c.MetricsPassword); err != nil { + return err + } + } + return nil } + +// metricsBindHostAllowsUnauthenticated returns true only for loopback binds +// where exposing /metrics without credentials is acceptable (local dev/tests). +func metricsBindHostAllowsUnauthenticated(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func validateMetricsAuth(addr, apiKey, username, password string) error { + if addr == "" { + return fmt.Errorf("metrics address is required when metrics are enabled") + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("invalid metrics address %q: %w", addr, err) + } + // Empty host (e.g. ":9090") means all interfaces β€” never allow open /metrics. + if host == "" { + host = "0.0.0.0" + } + + userOrPass := (username != "") != (password != "") + if userOrPass { + return fmt.Errorf("metrics: both username and password must be set for basic authentication") + } + + hasAuth := apiKey != "" || (username != "" && password != "") + if hasAuth { + return nil + } + if metricsBindHostAllowsUnauthenticated(host) { + return nil + } + return fmt.Errorf("metrics: set METRICS_API_KEY or METRICS_USERNAME and METRICS_PASSWORD when binding to %q (unauthenticated /metrics is only allowed on loopback)", addr) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8460c8f..a20af0d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -258,6 +258,52 @@ func TestValidate_BothCertAndKeyFiles(t *testing.T) { } } +func TestValidate_MetricsNonLoopbackRequiresAuth(t *testing.T) { + cfg := &Config{ + WebhookRetryMultiplier: DefaultWebhookRetryMultiplier, + EnableMetrics: true, + MetricsAddr: "0.0.0.0:9090", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for metrics on non-loopback without credentials") + } +} + +func TestValidate_MetricsLoopbackNoAuthOK(t *testing.T) { + cfg := &Config{ + WebhookRetryMultiplier: DefaultWebhookRetryMultiplier, + EnableMetrics: true, + MetricsAddr: "127.0.0.1:9090", + } + if err := cfg.Validate(); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidate_MetricsWildcardBindRequiresAuth(t *testing.T) { + cfg := &Config{ + WebhookRetryMultiplier: DefaultWebhookRetryMultiplier, + EnableMetrics: true, + MetricsAddr: ":9090", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for bind-all address without credentials") + } +} + +func TestValidate_MetricsPartialBasicAuthRejected(t *testing.T) { + cfg := &Config{ + WebhookRetryMultiplier: DefaultWebhookRetryMultiplier, + EnableMetrics: true, + MetricsAddr: "0.0.0.0:9090", + MetricsUsername: "admin", + MetricsPassword: "", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for partial basic auth") + } +} + func TestLoadFromFlags_WithWebhookURL(t *testing.T) { // Test webhook URL configuration by creating a config manually // to avoid flag redefinition issues diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 7609dc4..f2f5a0c 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -2,6 +2,9 @@ package observability import ( "context" + "crypto/subtle" + "log" + "net" "net/http" "strings" "sync" @@ -598,8 +601,7 @@ func (m *metricsServer) Start() error { go func() { if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - // Log error but don't fail the application - this is expected behavior - _ = err + log.Printf("metrics server: ListenAndServe: %v", err) } }() @@ -615,24 +617,54 @@ func (m *metricsServer) Stop(ctx context.Context) error { return m.server.Shutdown(ctx) } +// metricsAddrAllowsUnauthenticatedMetrics is true only for loopback binds where +// open /metrics is acceptable (local development and unit tests). +func metricsAddrAllowsUnauthenticatedMetrics(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil || host == "" { + return false + } + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func constantTimeEqualString(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + // authMiddleware provides authentication for the metrics endpoint func (m *metricsServer) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // If no authentication is configured, allow access - if m.config.MetricsAPIKey == "" && m.config.MetricsUsername == "" { - next.ServeHTTP(w, r) + hasBearer := m.config.MetricsAPIKey != "" + hasBasic := m.config.MetricsUsername != "" && m.config.MetricsPassword != "" + + if !hasBearer && !hasBasic { + if metricsAddrAllowsUnauthenticatedMetrics(m.config.MetricsAddr) { + next.ServeHTTP(w, r) + return + } + w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } authenticated := false // Check Bearer token authentication - if m.config.MetricsAPIKey != "" { + if hasBearer { authHeader := r.Header.Get("Authorization") if authHeader != "" { parts := strings.SplitN(authHeader, " ", 2) - if len(parts) == 2 && parts[0] == "Bearer" { - if parts[1] == m.config.MetricsAPIKey { + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + token := strings.TrimSpace(parts[1]) + if constantTimeEqualString(token, m.config.MetricsAPIKey) { authenticated = true } } @@ -640,9 +672,10 @@ func (m *metricsServer) authMiddleware(next http.Handler) http.Handler { } // Check Basic authentication - if !authenticated && m.config.MetricsUsername != "" && m.config.MetricsPassword != "" { + if !authenticated && hasBasic { username, password, ok := r.BasicAuth() - if ok && username == m.config.MetricsUsername && password == m.config.MetricsPassword { + if ok && username == m.config.MetricsUsername && + constantTimeEqualString(password, m.config.MetricsPassword) { authenticated = true } } diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index 11fcd2c..1910ff3 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -1253,6 +1253,41 @@ func TestMetricsServer_AuthMiddleware_HTTP(t *testing.T) { }) } +func TestMetricsServer_AuthMiddleware_NonLoopbackRequiresCredentials(t *testing.T) { + l, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + l.Close() + + config := MetricsConfig{ + EnableMetrics: true, + MetricsAddr: addr, + } + server := NewMetricsServer(config) + if err := server.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer server.Stop(context.Background()) + time.Sleep(80 * time.Millisecond) + + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("SplitHostPort: %v", err) + } + metricsURL := "http://" + net.JoinHostPort("127.0.0.1", port) + "/metrics" + + resp, err := http.Get(metricsURL) + if err != nil { + t.Fatalf("GET /metrics: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 without credentials on %s, got %d", addr, resp.StatusCode) + } +} + // TestMetricsServer_AuthMiddleware_BearerToken_HTTP tests bearer token auth via HTTP func TestMetricsServer_AuthMiddleware_BearerToken_HTTP(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -1298,6 +1333,19 @@ func TestMetricsServer_AuthMiddleware_BearerToken_HTTP(t *testing.T) { } }) + t.Run("bearer_scheme_case_insensitive", func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://"+addr+"/metrics", nil) + req.Header.Set("Authorization", "bearer secret-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + t.Run("wrong_bearer_token", func(t *testing.T) { req, _ := http.NewRequest("GET", "http://"+addr+"/metrics", nil) req.Header.Set("Authorization", "Bearer wrong-token") diff --git a/internal/smtp/server.go b/internal/smtp/server.go index 8bf42ee..ff8ece3 100644 --- a/internal/smtp/server.go +++ b/internal/smtp/server.go @@ -138,8 +138,9 @@ func (s *SMTPServer) listenAndServeWithProxy(server *smtp.Server, addr string) e Listener: ln, Policy: func(upstream net.Addr) (proxyproto.Policy, error) { if len(s.trustedProxyCIDRs) == 0 { - // No restriction configured β€” accept from any source - return proxyproto.USE, nil + // No trusted proxies configured β€” ignore PROXY headers so clients + // cannot spoof source IPs (SPF/DMARC rely on the real TCP peer). + return proxyproto.IGNORE, nil } host, _, err := net.SplitHostPort(upstream.String()) if err != nil { diff --git a/internal/smtp/server_test.go b/internal/smtp/server_test.go index 24734ab..da5df3b 100644 --- a/internal/smtp/server_test.go +++ b/internal/smtp/server_test.go @@ -734,12 +734,12 @@ func TestSMTPServer_Start_TLSAndStop(t *testing.T) { } } -func TestSMTPServer_ProxyPolicy_NoRestrictions(t *testing.T) { +func TestSMTPServer_ProxyPolicy_NoTrustedProxies(t *testing.T) { addr := getFreeAddr(t) config := ServerConfig{ ListenAddr: addr, ServerName: "test", - TrustedProxyCIDRs: nil, // no restriction β€” any source accepted + TrustedProxyCIDRs: nil, // no trusted proxies β€” PROXY headers ignored } backend := createMockBackend() logger := &mockLogger{} diff --git a/start.sh b/start.sh index 6b68f01..0242820 100644 --- a/start.sh +++ b/start.sh @@ -23,17 +23,10 @@ echo " Domain: ${DOMAIN}" echo " DNS API: ${DNS_API}" echo " ACME Email: ${ACME_SH_EMAIL}" -# Check if acme.sh is installed, if not install it +# acme.sh must be present in the image (installed at build time); never curl|sh at runtime if ! command -v acme.sh >/dev/null 2>&1; then - echo 'acme.sh not found, installing...' - curl https://get.acme.sh | sh -s email=${ACME_SH_EMAIL} - # Source the acme.sh environment - . ~/.acme.sh/acme.sh.env -fi - -# Ensure acme.sh env is loaded if available (path, aliases) -if [ -f ~/.acme.sh/acme.sh.env ]; then - . ~/.acme.sh/acme.sh.env + echo 'ERROR: acme.sh is not installed or not on PATH' + exit 1 fi # Set up account on first startup @@ -109,7 +102,7 @@ else fi # Start SMTP server with environment variables -./inboundparse \ +exec /usr/local/bin/inboundparse \ -webhook="${WEBHOOK_URL}" \ -enable-spf="${ENABLE_SPF}" \ -enable-dkim="${ENABLE_DKIM}" \ @@ -117,4 +110,3 @@ fi -cert-file="${CERT_FILE}" \ -key-file="${KEY_FILE}" \ -verbose="${VERBOSE}" - \ No newline at end of file