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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.git
.github
.env
data
frontend/node_modules
frontend/dist
coverage.out
support
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
SUPPORT_ADDRESS=:8080
SUPPORT_PUBLIC_URL=http://localhost:8080
SUPPORT_ENVIRONMENT=development
SUPPORT_OBJECT_ROOT=./data/private
SUPPORT_WEB_ROOT=./frontend/dist
SUPPORT_DATA_KEY=replace-with-base64-encoded-32-byte-key
DATABASE_URL=postgres://support:replace-me@localhost:5432/support?sslmode=disable
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Validate

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
service:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go test ./...
- run: go vet ./...

portal:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build

container:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.env
/data/
/support
coverage.out
frontend/node_modules/
frontend/dist/
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM node:24-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/support ./cmd/support
RUN mkdir -p /out/private

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/support /support
COPY --from=frontend /src/frontend/dist /srv/support-web
COPY --chown=65532:65532 --from=build /out/private /var/lib/obiente-support/private
VOLUME ["/var/lib/obiente-support/private"]
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/support"]
92 changes: 90 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,90 @@
# support
Support portal, issue intake, and private diagnostics for Obiente projects
# Obiente Support

Obiente Support is the product-neutral support portal and private intake service for Obiente projects. It lets people report bugs, suggest features, ask for help, and send explicitly approved diagnostics without needing a GitHub account.

The public portal is a Vue 3 and TypeScript application. A small Go service owns the versioned intake contract, private storage, idempotency, status capabilities, deletion, and retention.

## What this foundation includes

- Bug, feature, and general-support intake for products in a maintained registry.
- A versioned multipart API shared by the web portal and native applications.
- Explicit 4 MiB diagnostic upload bounds and per-product ZIP entry allowlists.
- Idempotent submission and receipt reconciliation after an uncertain response.
- Human-readable support codes and unguessable private status/deletion links.
- Application-level AES-256-GCM encryption for private report fields, capabilities, and diagnostic objects.
- Automatic private-data expiration and immediate deletion through the private capability.
- A second product registration and synthetic contract fixtures to prevent product-specific service code.
- A responsive, keyboard-operable Vue portal with reduced-motion behavior and upload cancellation.

This PR advances issue #1. Moderation queues, maintainer authentication, public tracker promotion, GitHub synchronization, safe follow-up messages, and audited maintainer access remain required before the complete issue can close.

## Local development

Requirements:

- Go 1.24 or newer
- Node.js 22 or newer
- PostgreSQL 17

Create a data key and local environment file:

```bash
cp .env.example .env
openssl rand -base64 32
```

Place the generated value in `SUPPORT_DATA_KEY`, set a database password, then start PostgreSQL:

```bash
docker compose up database
```

Build the Vue portal and run the same-origin service on port 8080:

```bash
cd frontend
npm ci
npm run build
cd ..
set -a
. ./.env
set +a
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:

```bash
docker compose up --build
```

## Validation

```bash
go test ./...
go vet ./...
cd frontend
npm ci
npm run lint
npm test
npm run build
```

## Privacy boundaries

- New reports are private. Nothing is published automatically.
- Diagnostic attachments are optional and must match the selected product's registered schema.
- The service does not store raw idempotency keys or raw status capabilities in database lookup columns.
- Private report text, contact details, receipt capabilities, and diagnostic objects are encrypted before storage.
- Support codes are identifiers, not authentication secrets.
- Private status URLs are bearer capabilities. Applications must never put them in diagnostics, telemetry, or public issues.
- The application does not submit diagnostics automatically, after a crash, or in the background.
- Server logs contain operational failures but no request body, capability path, contact data, diagnostic content, or remote address.

See [docs/threat-model.md](docs/threat-model.md), [docs/operations.md](docs/operations.md), and [openapi.yaml](openapi.yaml) for the initial security and integration contract.

## License

GNU Affero General Public License v3.0. See [LICENSE](LICENSE).
100 changes: 100 additions & 0 deletions cmd/support/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/obiente/support/internal/config"
"github.com/obiente/support/internal/cryptobox"
"github.com/obiente/support/internal/intake"
"github.com/obiente/support/internal/products"
"github.com/obiente/support/internal/store"
"github.com/obiente/support/internal/web"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if err := run(logger); err != nil {
logger.Error("support service stopped", "error", err)
os.Exit(1)
}
}

func run(logger *slog.Logger) error {
configuration, err := config.FromEnvironment()
if err != nil {
return err
}
box, err := cryptobox.NewFromBase64(configuration.DataKey)
if err != nil {
return err
}
objects, err := store.NewFileObjects(configuration.ObjectRoot, box)
if err != nil {
return err
}
startupContext, startupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer startupCancel()
reports, err := store.OpenPostgres(startupContext, configuration.DatabaseURL)
if err != nil {
return err
}
defer reports.Close()
if err := reports.Migrate(startupContext); err != nil {
return err
}
registry := products.Default()
intakeService := intake.New(reports, objects, registry, box, configuration.PublicURL)
if err := intakeService.PurgeExpired(startupContext, 250); err != nil {
logger.Warn("initial private-data retention purge did not complete", "error", err)
}
retentionContext, retentionCancel := context.WithCancel(context.Background())
defer retentionCancel()
go runRetention(retentionContext, intakeService, logger)
webServer, err := web.New(intakeService, registry, logger, configuration.WebRoot)
if err != nil {
return err
}
server := web.HTTPServer(configuration.Address, webServer.Handler())
shutdown, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
serverError := make(chan error, 1)
go func() {
logger.Info("support service listening", "address", configuration.Address, "environment", configuration.Environment)
serverError <- server.ListenAndServe()
}()
select {
case err := <-serverError:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-shutdown.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
return server.Shutdown(shutdownContext)
}
}

func runRetention(ctx context.Context, intakeService *intake.Service, logger *slog.Logger) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
purgeContext, cancel := context.WithTimeout(ctx, 5*time.Minute)
if err := intakeService.PurgeExpired(purgeContext, 250); err != nil {
logger.Warn("private-data retention purge did not complete", "error", err)
}
cancel()
}
}
}
47 changes: 47 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
services:
support:
build: .
restart: unless-stopped
environment:
SUPPORT_ADDRESS: ":8080"
SUPPORT_PUBLIC_URL: "${SUPPORT_PUBLIC_URL:-http://localhost:8080}"
SUPPORT_ENVIRONMENT: "${SUPPORT_ENVIRONMENT:-development}"
SUPPORT_OBJECT_ROOT: /var/lib/obiente-support/private
SUPPORT_WEB_ROOT: /srv/support-web
SUPPORT_DATA_KEY: "${SUPPORT_DATA_KEY:?set SUPPORT_DATA_KEY}"
DATABASE_URL: "postgres://support:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@database:5432/support?sslmode=disable"
depends_on:
database:
condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
volumes:
- private_reports:/var/lib/obiente-support/private
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL

database:
image: postgres:17-alpine
restart: unless-stopped
environment:
- POSTGRES_DB=support
- POSTGRES_USER=support
- POSTGRES_PASSWORD
healthcheck:
test: ["CMD-SHELL", "pg_isready -U support -d support"]
interval: 5s
timeout: 3s
retries: 20
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
- support_database:/var/lib/postgresql/data

volumes:
private_reports:
support_database:
38 changes: 38 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Operations

## 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_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`: private persistent volume writable only by the service user.
- `SUPPORT_WEB_ROOT`: built Vue distribution included in the production image.

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.

## Backups

Back up PostgreSQL and the private object volume as one retention unit. Encrypt backups independently from `SUPPORT_DATA_KEY`, restrict operator access, and apply the same deletion schedule to expired backups. A restore test must verify that database object keys and encrypted files remain consistent.

## Key rotation

The initial foundation supports one active data key. Do not replace it while private reports encrypted under the previous key remain. Dual-key reads and an audited re-encryption job are required before production key rotation. If the active key is exposed, stop intake, preserve audit evidence, notify the incident owner, and treat all retained private payloads as affected.

## Recovery

1. Restore PostgreSQL and the private object volume from the same backup generation.
2. Restore the exact `SUPPORT_DATA_KEY` version used for that generation.
3. Start the service without public traffic and verify `/healthz`.
4. Use synthetic fixtures to test create, reconcile, status, and deletion.
5. Confirm the retention purge completes before reopening intake.

Never inspect a real user's plaintext report as a recovery test.

## Data deletion

Capability deletion soft-revokes the report immediately and removes its diagnostic object. The hourly retention task hard-deletes revoked and expired rows. If object deletion fails, the row remains so cleanup can retry without losing the object reference.

## Incident response

Do not paste private reports, capabilities, contact details, database rows, or decrypted diagnostics into GitHub issues, chat, or CI logs. Use synthetic identifiers in public incident tracking and a separately authorized private evidence path for affected data.
Loading
Loading