From eed408bb1925c19934dab8a46e4eb932f5b712ca Mon Sep 17 00:00:00 2001 From: SoftwareEngineer Date: Mon, 22 Jun 2026 14:31:27 +0200 Subject: [PATCH] feat: foundation Go module, HTTP server, Docker packaging - Go module (github.com/elfoundation/hatch) with go 1.25 - cmd/hatch: HTTP server with GET /healthz returning 200 OK - Multi-stage Dockerfile producing static binary from scratch - docker-compose with hatch + optional Caddy reverse proxy - CI placeholder with Go test/lint stubs - .gitignore, .dockerignore, .env.example Co-Authored-By: Paperclip --- .dockerignore | 37 +++++++++++++++++++++++++ .env.example | 6 +++++ .github/workflows/ci.yml | 49 ++++++++++++++++++++++----------- .gitignore | 21 +++++++++++++++ Caddyfile | 19 +++++++++++++ Dockerfile | 24 +++++++++++++++++ README.md | 37 +++++++++++++++++++++++++ cmd/hatch/main.go | 36 +++++++++++++++++++++++++ cmd/hatch/main_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 33 +++++++++++++++++++++++ go.mod | 3 +++ 11 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 cmd/hatch/main.go create mode 100644 cmd/hatch/main_test.go create mode 100644 docker-compose.yml create mode 100644 go.mod diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1358a29b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Git +.git +.gitignore +.gitattributes + +# Docs (not needed at runtime) +docs/ + +# Editor config +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Environment (keep .env.example) +.env +.env.local +.env.*.local + +# Docker (avoid recursive copy) +Dockerfile +docker-compose.yml +.dockerignore + +# Node artifacts (from pre-migration scaffold, will be removed by ELF-15) +node_modules/ +pnpm-lock.yaml +package.json +apps/ + +# Test / build caches +*.test +coverage/ diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9ff276aa --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Public hostname for HTTPS (Caddy will obtain a Let's Encrypt cert automatically). +# Leave unset or set to "localhost" for local dev (self-signed cert). +HATCH_HOSTNAME=hatch.example.com + +# Base URL for SSE and self-referencing links within the UI. +HATCH_BASE_URL=https://hatch.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd058840..56695f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,40 @@ on: branches: [main] jobs: + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -v -race + + - name: Build + run: CGO_ENABLED=0 go build -o /dev/null ./cmd/hatch + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t hatch:ci . + + - name: Smoke test the Docker image + run: | + docker run --rm -d -p 8080:8080 --name hatch-ci hatch:ci + sleep 2 + curl -fsS http://localhost:8080/healthz | grep -q 'ok' + docker stop hatch-ci + lint-docs: runs-on: ubuntu-latest steps: @@ -16,18 +50,3 @@ jobs: with: globs: '**/*.md' continue-on-error: true - - # TODO: Enable once we have a TypeScript project - # lint-and-test: - # runs-on: ubuntu-latest - # steps: - # - uses: actions/checkout@v4 - # - uses: pnpm/action-setup@v4 - # - uses: actions/setup-node@v4 - # with: - # node-version: 22 - # cache: 'pnpm' - # - run: pnpm install --frozen-lockfile - # - run: pnpm lint - # - run: pnpm typecheck - # - run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..10976539 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Build +/bin +/dist + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 00000000..704e7b97 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,19 @@ +{$HATCH_HOSTNAME:localhost} { + reverse_proxy hatch:8080 + + # Log requests for debugging + log { + output stdout + format json + } + + # Security headers + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "no-referrer" + } + + # TLS: auto via Let's Encrypt when HATCH_HOSTNAME is a real domain. + # Falls back to self-signed for localhost / internal-only names. +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..def3ed14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +# ---- build stage ---- +FROM golang:1.25-alpine AS build + +WORKDIR /src + +# Cache module downloads before copying source (layer caching). +COPY go.mod ./ +RUN go mod download + +COPY . ./ + +# Build a fully static binary (CGO disabled, no libc dependency). +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/hatch ./cmd/hatch + +# ---- run stage ---- +FROM scratch + +COPY --from=build /bin/hatch /bin/hatch + +EXPOSE 8080 + +ENTRYPOINT ["/bin/hatch"] diff --git a/README.md b/README.md index 3f4bb3e5..b91e77d7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,43 @@ El Foundation builds institutions that outlast their founders. We create technol └── scripts/ # Automation and utility scripts ``` +## Hatch — Deploy in one command + +Hatch is a self-hostable HTTP request inspector + mocker. Ship it to any VPS with Docker. + +### Quick start (local dev, no HTTPS) + +```bash +docker compose up --build +# Hatch UI: http://localhost:8080 +# Capture endpoint: http://localhost:8080/{endpoint-id} +``` + +### Production (with HTTPS via Caddy) + +```bash +# Set your domain name +cp .env.example .env +# Edit HATCH_HOSTNAME in .env to your real domain + +# Start Hatch + Caddy (auto-issues Let's Encrypt cert) +docker compose --profile with-caddy up -d --build +# Hatch UI: https://{your-domain} +# Capture endpoint: https://{your-domain}/{endpoint-id} +``` + +### Architecture + +``` +Internet → :443 (Caddy) → hatch:8080 (Go binary, internal network) + │ + ├─ Auto TLS (Let's Encrypt, or self-signed for localhost) + ├─ Reverse proxy with security headers + └─ JSON access logs to stdout +``` + +Caddy terminates TLS and reverse-proxies to the Hatch Go binary. The Hatch container only listens on `127.0.0.1:8080` — it's never directly exposed to the internet. + ## Technology Stack See [docs/engineering/tech-stack.md](docs/engineering/tech-stack.md) for current choices and rationale. diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go new file mode 100644 index 00000000..493c035b --- /dev/null +++ b/cmd/hatch/main.go @@ -0,0 +1,36 @@ +// Command hatch is the Hatch HTTP request inspector + mocker server. +// +// Hatch captures, inspects, and mocks HTTP requests. It ships as a single +// static binary — one command on a VPS and your payloads never leave your +// network. +package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + + addr := fmt.Sprintf(":%s", port) + log.Printf("hatch starting on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("hatch server error: %v", err) + } +} + +// healthz returns 200 OK with body "ok" for liveness probes. +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") +} diff --git a/cmd/hatch/main_test.go b/cmd/hatch/main_test.go new file mode 100644 index 00000000..f9d5bb5e --- /dev/null +++ b/cmd/hatch/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthz(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + + healthz(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + got := strings.TrimSpace(string(body)) + if got != "ok" { + t.Fatalf("expected body 'ok', got '%s'", got) + } +} + +func TestHealthzSmoke(t *testing.T) { + // Smoke test: boot the server on a random port, hit /healthz, verify 200 + "ok". + srv := httptest.NewServer(http.HandlerFunc(healthz)) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatalf("failed to GET /healthz: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + got := strings.TrimSpace(string(body)) + if got != "ok" { + t.Fatalf("expected body 'ok', got '%s'", got) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..a3743bcc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + hatch: + build: . + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + volumes: + - hatch-data:/data + environment: + - HATCH_PORT=8080 + - HATCH_DB_PATH=/data/hatch.db + - HATCH_BASE_URL=${HATCH_BASE_URL:-http://localhost} + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + environment: + - HATCH_HOSTNAME=${HATCH_HOSTNAME:-localhost} + # Caddy runs even without a real hostname (self-signed) + profiles: + - with-caddy + +volumes: + hatch-data: + caddy-data: + caddy-config: diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..02f741ea --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/elfoundation/hatch + +go 1.25