Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
6 changes: 3 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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

Expand Down
27 changes: 24 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 20 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://[email protected]", 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")
Expand Down
10 changes: 9 additions & 1 deletion internal/intake/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading