diff --git a/.env.example b/.env.example index 98f08ba..04ef0a4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +# Local development values. Production uses an explicit HTTPS public origin. SUPPORT_ADDRESS=:8080 SUPPORT_PUBLIC_URL=http://localhost:8080 SUPPORT_ENVIRONMENT=development diff --git a/Dockerfile b/Dockerfile index 57c9ebc..d888098 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,8 @@ RUN mkdir -p /out/private FROM gcr.io/distroless/static-debian12:nonroot WORKDIR / +ENV SUPPORT_PUBLIC_URL=https://support.obiente.org \ + SUPPORT_ENVIRONMENT=production COPY --from=build /out/support /support COPY --from=frontend /src/frontend/dist ./frontend/dist COPY --chown=65532:65532 --from=build /out/private ./data/private diff --git a/README.md b/README.md index 579bac9..dfb2aff 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Requirements: - Node.js 22 or newer - PostgreSQL 17 -Create a data key and local environment file: +Create a data key and local development environment file: ```bash cp .env.example .env @@ -59,13 +59,13 @@ go run ./cmd/support For Vue hot reload, keep a built portal available for the Go process, set `SUPPORT_PUBLIC_URL=http://localhost:5173`, run `npm run dev`, and open port 5173. Vite proxies `/api` to the Go service on port 8080. -For the production-shaped single-container build: +For the production single-container build: ```bash docker compose up --build ``` -The support image contains both the built Vue portal and Go service. Mount persistent encrypted report storage at `/data`; the service stores diagnostic objects under `/data/private`. Runtime configuration is supplied through environment variables and is not built into the image. +The image and Compose service default to `SUPPORT_PUBLIC_URL=https://support.obiente.org` and `SUPPORT_ENVIRONMENT=production`. The development `.env` created above overrides those values, so do not reuse it for a production deployment. Production refuses to start when its public URL is missing or is not a plain HTTPS origin. The support image contains both the built Vue portal and Go service. Mount persistent encrypted report storage at `/data`; the service stores diagnostic objects under `/data/private`. The public intake is at `/`. Maintainers sign in at `/admin/login`. Admin credentials are runtime configuration; no default password is included in the image. diff --git a/compose.yaml b/compose.yaml index 851373b..5c8d4d6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,8 +4,8 @@ services: restart: unless-stopped environment: SUPPORT_ADDRESS: ":8080" - SUPPORT_PUBLIC_URL: "${SUPPORT_PUBLIC_URL:-http://localhost:8080}" - SUPPORT_ENVIRONMENT: "${SUPPORT_ENVIRONMENT:-development}" + SUPPORT_PUBLIC_URL: "${SUPPORT_PUBLIC_URL:-https://support.obiente.org}" + SUPPORT_ENVIRONMENT: "${SUPPORT_ENVIRONMENT:-production}" SUPPORT_DATA_KEY: "${SUPPORT_DATA_KEY:?set SUPPORT_DATA_KEY}" SUPPORT_ADMIN_USERNAME: "${SUPPORT_ADMIN_USERNAME:-admin}" SUPPORT_ADMIN_PASSWORD_HASH: "${SUPPORT_ADMIN_PASSWORD_HASH:?set SUPPORT_ADMIN_PASSWORD_HASH}" diff --git a/docs/operations.md b/docs/operations.md index d78d635..a5f167b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,8 +2,8 @@ ## Required production configuration -- `SUPPORT_PUBLIC_URL`: canonical HTTPS origin, for example `https://support.obiente.org`. -- `SUPPORT_ENVIRONMENT=production`: rejects a non-HTTPS public URL. +- `SUPPORT_PUBLIC_URL`: canonical HTTPS origin. The Obiente deployment uses `https://support.obiente.org`. +- `SUPPORT_ENVIRONMENT=production`: requires `SUPPORT_PUBLIC_URL` and rejects HTTP, user information, non-root paths, queries, and fragments. - `SUPPORT_DATA_KEY`: base64-encoded 32-byte key from a cryptographically secure source. - `DATABASE_URL`: PostgreSQL connection with a dedicated least-privilege database user. - `SUPPORT_OBJECT_ROOT`: optional object-root override. The production image uses `/data/private`; mount the persistent private-data volume at `/data`. @@ -13,7 +13,7 @@ Terminate HTTPS at a trusted reverse proxy. Do not expose PostgreSQL or the private object volume. Do not enable request-body logging at the proxy. -The production image builds both the Vue portal and Go service and declares a Docker health check against `GET /healthz`. The probe uses `SUPPORT_ADDRESS` when set and defaults to port 8080. It does not require a shell or additional utility in the final image. +The production image and Compose service default to `SUPPORT_PUBLIC_URL=https://support.obiente.org` and `SUPPORT_ENVIRONMENT=production`. Override the public URL only when deploying a separate support origin. The production image builds both the Vue portal and Go service and declares a Docker health check against `GET /healthz`. The probe uses `SUPPORT_ADDRESS` when set and defaults to port 8080. It does not require a shell or additional utility in the final image. ## Maintainer access diff --git a/internal/config/config.go b/internal/config/config.go index 271df57..77984c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,24 +25,45 @@ type Config struct { } func FromEnvironment() (Config, error) { + environment := valueOrDefault("SUPPORT_ENVIRONMENT", "development") + publicURL := strings.TrimSpace(os.Getenv("SUPPORT_PUBLIC_URL")) + if publicURL == "" { + if environment == "production" { + return Config{}, errors.New("production SUPPORT_PUBLIC_URL is required") + } + publicURL = "http://localhost:8080" + } config := Config{ Address: valueOrDefault("SUPPORT_ADDRESS", ":8080"), - PublicURL: strings.TrimSuffix(valueOrDefault("SUPPORT_PUBLIC_URL", "http://localhost:8080"), "/"), + PublicURL: publicURL, DatabaseURL: strings.TrimSpace(os.Getenv("DATABASE_URL")), DataKey: strings.TrimSpace(os.Getenv("SUPPORT_DATA_KEY")), ObjectRoot: valueOrDefault("SUPPORT_OBJECT_ROOT", "./data/private"), WebRoot: valueOrDefault("SUPPORT_WEB_ROOT", "./frontend/dist"), - Environment: valueOrDefault("SUPPORT_ENVIRONMENT", "development"), + Environment: environment, AdminUsername: strings.TrimSpace(os.Getenv("SUPPORT_ADMIN_USERNAME")), AdminPasswordHash: strings.TrimSpace(os.Getenv("SUPPORT_ADMIN_PASSWORD_HASH")), } parsed, err := url.Parse(config.PublicURL) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { + if err != nil || parsed.Opaque != "" || parsed.Host == "" || parsed.Scheme != "http" && parsed.Scheme != "https" { return Config{}, errors.New("SUPPORT_PUBLIC_URL must be an absolute HTTP or HTTPS URL") } + if parsed.User != nil { + return Config{}, errors.New("SUPPORT_PUBLIC_URL must not contain user information") + } + if parsed.Path != "" && parsed.Path != "/" { + return Config{}, errors.New("SUPPORT_PUBLIC_URL must not contain a non-root path") + } + if parsed.RawQuery != "" || parsed.ForceQuery { + return Config{}, errors.New("SUPPORT_PUBLIC_URL must not contain a query") + } + if parsed.Fragment != "" { + return Config{}, errors.New("SUPPORT_PUBLIC_URL must not contain a fragment") + } if config.Environment == "production" && parsed.Scheme != "https" { return Config{}, errors.New("production SUPPORT_PUBLIC_URL must use HTTPS") } + config.PublicURL = strings.TrimSuffix(config.PublicURL, "/") config.SecureCookies = parsed.Scheme == "https" if config.DatabaseURL == "" { return Config{}, errors.New("DATABASE_URL is required") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b569a1b..defa8ea 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -28,9 +28,26 @@ func TestProductionRequiresHTTPSAndBcryptAdminCredentials(t *testing.T) { t.Fatal("production HTTPS did not enable secure admin cookies") } - t.Setenv("SUPPORT_PUBLIC_URL", "http://support.example") - if _, err := FromEnvironment(); err == nil || !strings.Contains(err.Error(), "HTTPS") { - t.Fatalf("HTTP production error = %v", err) + invalidPublicURLs := []struct { + name string + publicURL string + message string + }{ + {name: "omitted", message: "required"}, + {name: "HTTP", publicURL: "http://support.example", message: "HTTPS"}, + {name: "user information", publicURL: "https://operator@support.example", message: "user information"}, + {name: "non-root path", publicURL: "https://support.example/private", message: "non-root path"}, + {name: "query", publicURL: "https://support.example?source=app", message: "query"}, + {name: "empty query", publicURL: "https://support.example?", message: "query"}, + {name: "fragment", publicURL: "https://support.example#receipt", message: "fragment"}, + } + for _, test := range invalidPublicURLs { + t.Run(test.name, func(t *testing.T) { + t.Setenv("SUPPORT_PUBLIC_URL", test.publicURL) + if _, err := FromEnvironment(); err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("production public URL error = %v, want %q", err, test.message) + } + }) } t.Setenv("SUPPORT_PUBLIC_URL", "https://support.example") t.Setenv("SUPPORT_ADMIN_PASSWORD_HASH", "plaintext") diff --git a/internal/intake/service_test.go b/internal/intake/service_test.go index 4cf557c..c37e9d7 100644 --- a/internal/intake/service_test.go +++ b/internal/intake/service_test.go @@ -34,6 +34,14 @@ func TestSubmitReconcilesWithoutDuplicatingPrivateReport(t *testing.T) { if first.SupportCode != second.SupportCode || first.StatusURL != reconciled.StatusURL { t.Fatalf("receipts differ: first=%#v second=%#v reconciled=%#v", first, second, reconciled) } + for name, receipt := range map[string]domain.Receipt{"initial": first, "retry": second, "reconciled": reconciled} { + if !strings.HasPrefix(receipt.StatusURL, "https://support.obiente.org/r/") { + t.Fatalf("%s status URL = %q, want canonical configured origin", name, receipt.StatusURL) + } + if receipt.DeletionURL != receipt.StatusURL { + t.Fatalf("%s deletion URL = %q, want %q", name, receipt.DeletionURL, receipt.StatusURL) + } + } if len(objects.Values) != 1 { t.Fatalf("private object count = %d, want 1", len(objects.Values)) } @@ -237,7 +245,7 @@ func testService(t *testing.T) (*Service, *store.Memory, *store.MemoryObjects) { } reports := store.NewMemory() objects := store.NewMemoryObjects() - service := New(reports, objects, registry, box, "https://support.example") + service := New(reports, objects, registry, box, "https://support.obiente.org") service.now = func() time.Time { return time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) } return service, reports, objects }