diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92d8482 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Local development only. Replace every non-empty value outside localhost. +POSTGRES_PASSWORD=hopweb-local-postgres +HOP_ADMIN_TOKEN=hopweb-local-admin-token-change-me +HOP_GITEA_WEBHOOK_SECRET=hopweb-local-webhook-secret-change-me + +# Create a Gitea access token after first login, then set it here to enable +# POST /api/v1/repositories/link. +GITEA_API_TOKEN= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aa9fc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.DS_Store +bin/ +coverage.out + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d93a52c --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: build config down logs test up + +build: + cd services/control-plane && go build -buildvcs=false ./... + +config: + docker compose --env-file .env.example config --quiet + +down: + docker compose down + +logs: + docker compose logs -f + +test: + cd services/control-plane && go test -buildvcs=false ./... + +up: + test -f .env || cp .env.example .env + docker compose up --build -d diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ca72ae2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,76 @@ +name: hopweb + +services: + postgres: + image: postgres:17.6-alpine + environment: + POSTGRES_DB: gitea + POSTGRES_USER: hopweb + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?copy .env.example to .env first} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hopweb -d gitea"] + interval: 3s + timeout: 3s + retries: 20 + volumes: + - postgres-data:/var/lib/postgresql/data + - ./deploy/postgres/init:/docker-entrypoint-initdb.d:ro + networks: [hopweb] + + gitea: + image: docker.gitea.com/gitea:1.26.4 + depends_on: + postgres: + condition: service_healthy + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: postgres:5432 + GITEA__database__NAME: gitea + GITEA__database__USER: hopweb + GITEA__database__PASSWD: ${POSTGRES_PASSWORD} + GITEA__server__DOMAIN: localhost + GITEA__server__ROOT_URL: http://localhost:3000/ + GITEA__server__SSH_DOMAIN: localhost + GITEA__server__SSH_PORT: "2222" + GITEA__service__DISABLE_REGISTRATION: "false" + GITEA__security__INSTALL_LOCK: "true" + GITEA__webhook__ALLOWED_HOST_LIST: control-plane + ports: + - "3000:3000" + - "2222:22" + volumes: + - gitea-data:/data + networks: [hopweb] + + control-plane: + build: + context: ./services/control-plane + depends_on: + postgres: + condition: service_healthy + gitea: + condition: service_started + environment: + HOP_HTTP_ADDR: :8080 + HOP_DATABASE_URL: postgres://hopweb:${POSTGRES_PASSWORD}@postgres:5432/hop?sslmode=disable + HOP_ADMIN_TOKEN: ${HOP_ADMIN_TOKEN:?set HOP_ADMIN_TOKEN in .env} + HOP_GITEA_WEBHOOK_SECRET: ${HOP_GITEA_WEBHOOK_SECRET:?set HOP_GITEA_WEBHOOK_SECRET in .env} + GITEA_BASE_URL: http://gitea:3000 + GITEA_API_TOKEN: ${GITEA_API_TOKEN:-} + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:8080/readyz"] + interval: 5s + timeout: 3s + retries: 20 + networks: [hopweb] + +networks: + hopweb: + +volumes: + gitea-data: + postgres-data: diff --git a/deploy/postgres/init/001-create-hop-database.sql b/deploy/postgres/init/001-create-hop-database.sql new file mode 100644 index 0000000..a7c85d5 --- /dev/null +++ b/deploy/postgres/init/001-create-hop-database.sql @@ -0,0 +1,2 @@ +CREATE DATABASE hop OWNER hopweb; + diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..0270d47 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,64 @@ +# Local development + +## Start the foundation + +Requirements: Docker with Compose v2, Go 1.26 or newer for host-side tests, and +ports 3000, 8080, and 2222 available. + +```sh +cp .env.example .env +make up +``` + +Open Gitea at . The Hop control plane exposes health at + and readiness at +. + +Gitea self-registration is enabled for this local stack. After creating the +first account, create an access token in Gitea and set `GITEA_API_TOKEN` in +`.env`, then restart the control plane: + +```sh +docker compose up -d --force-recreate control-plane +``` + +## Link a repository + +Create a repository in Gitea, then ask Hop to verify it through the Gitea API +and record the link: + +```sh +set -a +. ./.env +set +a + +curl --fail-with-body \ + -H "Authorization: Bearer $HOP_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"owner":"YOUR_USER","name":"YOUR_REPOSITORY"}' \ + http://localhost:8080/api/v1/repositories/link +``` + +## Configure a webhook + +In the Gitea repository, create a Gitea webhook with: + +- target URL: `http://control-plane:8080/api/v1/gitea/webhooks` +- content type: `application/json` +- secret: the value of `HOP_GITEA_WEBHOOK_SECRET` in `.env` +- events: all events + +The control plane verifies `X-Gitea-Signature` against the raw request body, +records each delivery exactly once, and refreshes repository metadata from the +signed payload. + +## Useful commands + +```sh +make test +make config +make logs +make down +``` + +To destroy local data as well as containers, run `docker compose down -v`. diff --git a/services/control-plane/Dockerfile b/services/control-plane/Dockerfile new file mode 100644 index 0000000..53311a6 --- /dev/null +++ b/services/control-plane/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.26-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/hop-control-plane ./cmd/hop-control-plane + +FROM alpine:3.23 +RUN apk add --no-cache ca-certificates && addgroup -S hop && adduser -S -G hop hop +COPY --from=build /out/hop-control-plane /usr/local/bin/hop-control-plane +USER hop +EXPOSE 8080 +ENTRYPOINT ["hop-control-plane"] + diff --git a/services/control-plane/cmd/hop-control-plane/main.go b/services/control-plane/cmd/hop-control-plane/main.go new file mode 100644 index 0000000..512b6fd --- /dev/null +++ b/services/control-plane/cmd/hop-control-plane/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "hopweb/internal/config" + "hopweb/internal/gitea" + "hopweb/internal/httpapi" + "hopweb/internal/store" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + db, err := store.Open(ctx, cfg.DatabaseURL) + if err != nil { + logger.Error("open database", "error", err) + os.Exit(1) + } + defer db.Close() + + if err := db.Migrate(ctx); err != nil { + logger.Error("migrate database", "error", err) + os.Exit(1) + } + + giteaClient, err := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken) + if err != nil { + logger.Error("configure gitea client", "error", err) + os.Exit(1) + } + + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: httpapi.New(db, giteaClient, cfg.AdminToken, cfg.GiteaWebhookSecret, logger), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + logger.Info("control plane listening", "addr", cfg.HTTPAddr) + errCh <- server.ListenAndServe() + }() + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + logger.Error("serve", "error", err) + os.Exit(1) + } + case <-ctx.Done(): + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", "error", err) + os.Exit(1) + } + } +} diff --git a/services/control-plane/go.mod b/services/control-plane/go.mod new file mode 100644 index 0000000..aeea129 --- /dev/null +++ b/services/control-plane/go.mod @@ -0,0 +1,13 @@ +module hopweb + +go 1.26.0 + +require github.com/jackc/pgx/v5 v5.10.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/services/control-plane/go.sum b/services/control-plane/go.sum new file mode 100644 index 0000000..c0e505b --- /dev/null +++ b/services/control-plane/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/services/control-plane/internal/config/config.go b/services/control-plane/internal/config/config.go new file mode 100644 index 0000000..5fc724c --- /dev/null +++ b/services/control-plane/internal/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "errors" + "os" +) + +type Config struct { + HTTPAddr string + DatabaseURL string + AdminToken string + GiteaWebhookSecret string + GiteaBaseURL string + GiteaAPIToken string +} + +func Load() (Config, error) { + cfg := Config{ + HTTPAddr: value("HOP_HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("HOP_DATABASE_URL"), + AdminToken: os.Getenv("HOP_ADMIN_TOKEN"), + GiteaWebhookSecret: os.Getenv("HOP_GITEA_WEBHOOK_SECRET"), + GiteaBaseURL: value("GITEA_BASE_URL", "http://localhost:3000"), + GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"), + } + + if cfg.DatabaseURL == "" { + return Config{}, errors.New("HOP_DATABASE_URL is required") + } + if cfg.AdminToken == "" { + return Config{}, errors.New("HOP_ADMIN_TOKEN is required") + } + if len(cfg.AdminToken) < 16 { + return Config{}, errors.New("HOP_ADMIN_TOKEN must be at least 16 characters") + } + if cfg.GiteaWebhookSecret == "" { + return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET is required") + } + if len(cfg.GiteaWebhookSecret) < 16 { + return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET must be at least 16 characters") + } + return cfg, nil +} + +func value(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} diff --git a/services/control-plane/internal/config/config_test.go b/services/control-plane/internal/config/config_test.go new file mode 100644 index 0000000..9d48581 --- /dev/null +++ b/services/control-plane/internal/config/config_test.go @@ -0,0 +1,27 @@ +package config + +import "testing" + +func TestLoadRequiresSecretsAndDatabase(t *testing.T) { + t.Setenv("HOP_DATABASE_URL", "") + t.Setenv("HOP_ADMIN_TOKEN", "") + t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "") + if _, err := Load(); err == nil { + t.Fatal("expected missing configuration error") + } +} + +func TestLoadDefaults(t *testing.T) { + t.Setenv("HOP_DATABASE_URL", "postgres://example") + t.Setenv("HOP_ADMIN_TOKEN", "admin-token-long-enough") + t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "webhook-secret-long-enough") + t.Setenv("HOP_HTTP_ADDR", "") + t.Setenv("GITEA_BASE_URL", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.HTTPAddr != ":8080" || cfg.GiteaBaseURL != "http://localhost:3000" { + t.Fatalf("unexpected defaults: %#v", cfg) + } +} diff --git a/services/control-plane/internal/gitea/client.go b/services/control-plane/internal/gitea/client.go new file mode 100644 index 0000000..4df0da2 --- /dev/null +++ b/services/control-plane/internal/gitea/client.go @@ -0,0 +1,80 @@ +package gitea + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +var ErrTokenNotConfigured = errors.New("gitea API token is not configured") + +type Repository struct { + ID int64 `json:"id"` + Owner Owner `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + DefaultBranch string `json:"default_branch"` +} + +type Owner struct { + Login string `json:"login"` + Username string `json:"username"` +} + +func (r Repository) OwnerName() string { + if r.Owner.Login != "" { + return r.Owner.Login + } + return r.Owner.Username +} + +type Client struct { + baseURL *url.URL + token string + http *http.Client +} + +func NewClient(baseURL, token string) (*Client, error) { + u, err := url.Parse(strings.TrimRight(baseURL, "/")) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("invalid GITEA_BASE_URL %q", baseURL) + } + return &Client{baseURL: u, token: token, http: &http.Client{Timeout: 10 * time.Second}}, nil +} + +func (c *Client) Repository(ctx context.Context, owner, name string) (Repository, error) { + if c.token == "" { + return Repository{}, ErrTokenNotConfigured + } + u := c.baseURL.JoinPath("api", "v1", "repos", owner, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return Repository{}, err + } + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return Repository{}, fmt.Errorf("request repository: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Repository{}, fmt.Errorf("gitea repository lookup returned %s", resp.Status) + } + + var repo Repository + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return Repository{}, fmt.Errorf("decode repository: %w", err) + } + if repo.ID <= 0 || repo.OwnerName() == "" || repo.Name == "" { + return Repository{}, errors.New("gitea returned an incomplete repository") + } + return repo, nil +} diff --git a/services/control-plane/internal/gitea/client_test.go b/services/control-plane/internal/gitea/client_test.go new file mode 100644 index 0000000..39c41be --- /dev/null +++ b/services/control-plane/internal/gitea/client_test.go @@ -0,0 +1,44 @@ +package gitea + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRepositoryUsesTokenAndDecodesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gitea/api/v1/repos/hop/demo" { + t.Fatalf("path = %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "token gitea-token" { + t.Fatalf("authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo","clone_url":"http://gitea/hop/demo.git","default_branch":"main"}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL+"/gitea", "gitea-token") + if err != nil { + t.Fatal(err) + } + repository, err := client.Repository(context.Background(), "hop", "demo") + if err != nil { + t.Fatal(err) + } + if repository.ID != 42 || repository.OwnerName() != "hop" || repository.DefaultBranch != "main" { + t.Fatalf("unexpected repository: %#v", repository) + } +} + +func TestRepositoryRequiresToken(t *testing.T) { + client, err := NewClient("http://gitea.test", "") + if err != nil { + t.Fatal(err) + } + if _, err := client.Repository(context.Background(), "hop", "demo"); err != ErrTokenNotConfigured { + t.Fatalf("error = %v, want %v", err, ErrTokenNotConfigured) + } +} diff --git a/services/control-plane/internal/gitea/webhook.go b/services/control-plane/internal/gitea/webhook.go new file mode 100644 index 0000000..ff11f7b --- /dev/null +++ b/services/control-plane/internal/gitea/webhook.go @@ -0,0 +1,17 @@ +package gitea + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +func VerifySignature(body []byte, signature, secret string) bool { + provided, err := hex.DecodeString(signature) + if err != nil { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + return hmac.Equal(provided, mac.Sum(nil)) +} diff --git a/services/control-plane/internal/gitea/webhook_test.go b/services/control-plane/internal/gitea/webhook_test.go new file mode 100644 index 0000000..2c5f87b --- /dev/null +++ b/services/control-plane/internal/gitea/webhook_test.go @@ -0,0 +1,25 @@ +package gitea + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestVerifySignature(t *testing.T) { + body := []byte(`{"hello":"hop"}`) + mac := hmac.New(sha256.New, []byte("secret")) + _, _ = mac.Write(body) + signature := hex.EncodeToString(mac.Sum(nil)) + + if !VerifySignature(body, signature, "secret") { + t.Fatal("expected valid signature") + } + if VerifySignature(body, signature, "wrong") { + t.Fatal("accepted signature with wrong secret") + } + if VerifySignature(body, "not-hex", "secret") { + t.Fatal("accepted malformed signature") + } +} diff --git a/services/control-plane/internal/httpapi/server.go b/services/control-plane/internal/httpapi/server.go new file mode 100644 index 0000000..0679b09 --- /dev/null +++ b/services/control-plane/internal/httpapi/server.go @@ -0,0 +1,190 @@ +package httpapi + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "hopweb/internal/gitea" + "hopweb/internal/store" +) + +const maxWebhookBody = 2 << 20 + +type giteaClient interface { + Repository(context.Context, string, string) (gitea.Repository, error) +} + +type server struct { + store store.Store + gitea giteaClient + adminToken string + webhookSecret string + logger *slog.Logger +} + +func New(data store.Store, client giteaClient, adminToken, webhookSecret string, logger *slog.Logger) http.Handler { + s := &server{store: data, gitea: client, adminToken: adminToken, webhookSecret: webhookSecret, logger: logger} + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.health) + mux.HandleFunc("GET /readyz", s.ready) + mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories))) + mux.Handle("POST /api/v1/repositories/link", s.requireAdmin(http.HandlerFunc(s.linkRepository))) + mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook) + return requestLogger(logger, mux) +} + +func (s *server) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *server) ready(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.store.Ping(ctx); err != nil { + writeError(w, http.StatusServiceUnavailable, "database unavailable") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} + +func (s *server) listRepositories(w http.ResponseWriter, r *http.Request) { + repositories, err := s.store.ListRepositories(r.Context()) + if err != nil { + s.logger.Error("list repositories", "error", err) + writeError(w, http.StatusInternalServerError, "could not list repositories") + return + } + writeJSON(w, http.StatusOK, map[string]any{"repositories": repositories}) +} + +func (s *server) linkRepository(w http.ResponseWriter, r *http.Request) { + var input struct { + Owner string `json:"owner"` + Name string `json:"name"` + } + if err := decodeJSON(w, r, &input); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if !validSlug(input.Owner) || !validSlug(input.Name) { + writeError(w, http.StatusBadRequest, "owner and name are required and cannot contain slashes") + return + } + repository, err := s.gitea.Repository(r.Context(), input.Owner, input.Name) + if err != nil { + if errors.Is(err, gitea.ErrTokenNotConfigured) { + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + } + s.logger.Warn("gitea repository lookup failed", "owner", input.Owner, "name", input.Name, "error", err) + writeError(w, http.StatusBadGateway, "could not load repository from Gitea") + return + } + saved, err := s.store.UpsertRepository(r.Context(), repository) + if err != nil { + s.logger.Error("link repository", "error", err) + writeError(w, http.StatusInternalServerError, "could not link repository") + return + } + writeJSON(w, http.StatusCreated, saved) +} + +func (s *server) giteaWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody)) + if err != nil { + writeError(w, http.StatusRequestEntityTooLarge, "webhook payload too large") + return + } + if !gitea.VerifySignature(body, r.Header.Get("X-Gitea-Signature"), s.webhookSecret) { + writeError(w, http.StatusUnauthorized, "invalid webhook signature") + return + } + deliveryID := strings.TrimSpace(r.Header.Get("X-Gitea-Delivery")) + event := strings.TrimSpace(r.Header.Get("X-Gitea-Event")) + if deliveryID == "" || event == "" { + writeError(w, http.StatusBadRequest, "missing Gitea delivery or event header") + return + } + var payload struct { + Repository *gitea.Repository `json:"repository"` + } + if err := json.Unmarshal(body, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON payload") + return + } + digest := sha256.Sum256(body) + duplicate, err := s.store.RecordWebhook(r.Context(), store.WebhookDelivery{ + DeliveryID: deliveryID, Event: event, EventType: r.Header.Get("X-Gitea-Event-Type"), + PayloadSHA256: hex.EncodeToString(digest[:]), Repository: payload.Repository, + }) + if err != nil { + s.logger.Error("record webhook", "delivery_id", deliveryID, "error", err) + writeError(w, http.StatusInternalServerError, "could not record webhook") + return + } + if duplicate { + w.Header().Set("X-Hop-Duplicate", "true") + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *server) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization := r.Header.Get("Authorization") + if !strings.HasPrefix(authorization, "Bearer ") { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + provided := strings.TrimPrefix(authorization, "Bearer ") + if len(provided) != len(s.adminToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.adminToken)) != 1 { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) +} + +func validSlug(value string) bool { + value = strings.TrimSpace(value) + return value != "" && !strings.ContainsAny(value, "/\\") +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return errors.New("invalid JSON body") + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return errors.New("request body must contain one JSON object") + } + return nil +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func requestLogger(logger *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + next.ServeHTTP(w, r) + logger.Info("request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds()) + }) +} diff --git a/services/control-plane/internal/httpapi/server_test.go b/services/control-plane/internal/httpapi/server_test.go new file mode 100644 index 0000000..915f5ec --- /dev/null +++ b/services/control-plane/internal/httpapi/server_test.go @@ -0,0 +1,116 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "hopweb/internal/gitea" + "hopweb/internal/store" +) + +type fakeStore struct { + repositories []store.Repository + delivery store.WebhookDelivery + duplicate bool +} + +func (f *fakeStore) Ping(context.Context) error { return nil } +func (f *fakeStore) ListRepositories(context.Context) ([]store.Repository, error) { + return f.repositories, nil +} +func (f *fakeStore) UpsertRepository(_ context.Context, repository gitea.Repository) (store.Repository, error) { + saved := store.Repository{ID: "RP_TEST", GiteaID: repository.ID, Owner: repository.OwnerName(), Name: repository.Name, FullName: repository.FullName} + f.repositories = append(f.repositories, saved) + return saved, nil +} +func (f *fakeStore) RecordWebhook(_ context.Context, delivery store.WebhookDelivery) (bool, error) { + f.delivery = delivery + return f.duplicate, nil +} + +type fakeGitea struct { + repository gitea.Repository + err error +} + +func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) { + return f.repository, f.err +} + +func testServer(data store.Store, client giteaClient) http.Handler { + return New(data, client, "admin-secret", "webhook-secret", slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func TestAdminAuthentication(t *testing.T) { + handler := testServer(&fakeStore{}, fakeGitea{}) + for _, header := range []string{"", "admin-secret", "Bearer wrong"} { + req := httptest.NewRequest(http.MethodGet, "/api/v1/repositories", nil) + req.Header.Set("Authorization", header) + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("header %q: status = %d, want %d", header, response.Code, http.StatusUnauthorized) + } + } +} + +func TestLinkRepository(t *testing.T) { + data := &fakeStore{} + client := fakeGitea{repository: gitea.Repository{ID: 42, Owner: gitea.Owner{Login: "hop"}, Name: "demo", FullName: "hop/demo"}} + handler := testServer(data, client) + req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/link", bytes.NewBufferString(`{"owner":"hop","name":"demo"}`)) + req.Header.Set("Authorization", "Bearer admin-secret") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String()) + } + if len(data.repositories) != 1 || data.repositories[0].GiteaID != 42 { + t.Fatalf("repository not linked: %#v", data.repositories) + } +} + +func TestWebhookVerificationAndDeduplication(t *testing.T) { + data := &fakeStore{duplicate: true} + handler := testServer(data, fakeGitea{err: errors.New("unused")}) + body := []byte(`{"repository":{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo"}}`) + mac := hmac.New(sha256.New, []byte("webhook-secret")) + _, _ = mac.Write(body) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewReader(body)) + req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil))) + req.Header.Set("X-Gitea-Delivery", "delivery-1") + req.Header.Set("X-Gitea-Event", "push") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusNoContent, response.Body.String()) + } + if response.Header().Get("X-Hop-Duplicate") != "true" { + t.Fatal("expected duplicate response header") + } + if data.delivery.DeliveryID != "delivery-1" || data.delivery.Repository == nil || data.delivery.Repository.ID != 42 { + t.Fatalf("delivery not recorded: %#v", data.delivery) + } +} + +func TestWebhookRejectsInvalidSignature(t *testing.T) { + handler := testServer(&fakeStore{}, fakeGitea{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewBufferString(`{}`)) + req.Header.Set("X-Gitea-Signature", "bad") + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized) + } +} diff --git a/services/control-plane/internal/ids/ids.go b/services/control-plane/internal/ids/ids.go new file mode 100644 index 0000000..4c4ddb8 --- /dev/null +++ b/services/control-plane/internal/ids/ids.go @@ -0,0 +1,24 @@ +package ids + +import ( + "crypto/rand" + "encoding/base32" + "time" +) + +var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding) + +func Repository() (string, error) { + value := make([]byte, 16) + milliseconds := uint64(time.Now().UnixMilli()) + value[0] = byte(milliseconds >> 40) + value[1] = byte(milliseconds >> 32) + value[2] = byte(milliseconds >> 24) + value[3] = byte(milliseconds >> 16) + value[4] = byte(milliseconds >> 8) + value[5] = byte(milliseconds) + if _, err := rand.Read(value[6:]); err != nil { + return "", err + } + return "RP_" + encoding.EncodeToString(value), nil +} diff --git a/services/control-plane/internal/ids/ids_test.go b/services/control-plane/internal/ids/ids_test.go new file mode 100644 index 0000000..10622ff --- /dev/null +++ b/services/control-plane/internal/ids/ids_test.go @@ -0,0 +1,23 @@ +package ids + +import ( + "strings" + "testing" +) + +func TestRepository(t *testing.T) { + first, err := Repository() + if err != nil { + t.Fatal(err) + } + second, err := Repository() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(first, "RP_") || len(first) != 29 { + t.Fatalf("unexpected repository ID %q", first) + } + if first == second { + t.Fatal("generated duplicate IDs") + } +} diff --git a/services/control-plane/internal/store/migrations/001_foundation.sql b/services/control-plane/internal/store/migrations/001_foundation.sql new file mode 100644 index 0000000..0f00bab --- /dev/null +++ b/services/control-plane/internal/store/migrations/001_foundation.sql @@ -0,0 +1,21 @@ +CREATE TABLE repositories ( + id text PRIMARY KEY, + gitea_id bigint NOT NULL UNIQUE CHECK (gitea_id > 0), + owner text NOT NULL, + name text NOT NULL, + full_name text NOT NULL, + clone_url text NOT NULL DEFAULT '', + default_branch text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (owner, name) +); + +CREATE TABLE webhook_deliveries ( + delivery_id text PRIMARY KEY, + event text NOT NULL, + event_type text NOT NULL DEFAULT '', + payload_sha256 text NOT NULL, + received_at timestamptz NOT NULL DEFAULT now() +); + diff --git a/services/control-plane/internal/store/store.go b/services/control-plane/internal/store/store.go new file mode 100644 index 0000000..d080a2e --- /dev/null +++ b/services/control-plane/internal/store/store.go @@ -0,0 +1,191 @@ +package store + +import ( + "context" + "embed" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "hopweb/internal/gitea" + "hopweb/internal/ids" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +type Repository struct { + ID string `json:"id"` + GiteaID int64 `json:"gitea_id"` + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + DefaultBranch string `json:"default_branch"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WebhookDelivery struct { + DeliveryID string + Event string + EventType string + PayloadSHA256 string + Repository *gitea.Repository +} + +type Store interface { + Ping(context.Context) error + ListRepositories(context.Context) ([]Repository, error) + UpsertRepository(context.Context, gitea.Repository) (Repository, error) + RecordWebhook(context.Context, WebhookDelivery) (bool, error) +} + +type Postgres struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, databaseURL string) (*Postgres, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + return &Postgres{pool: pool}, nil +} + +func (p *Postgres) Close() { p.pool.Close() } + +func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) } + +func (p *Postgres) Migrate(ctx context.Context) error { + if _, err := p.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + return err + } + entries, err := migrations.ReadDir("migrations") + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := entry.Name() + var exists bool + if err := p.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&exists); err != nil { + return err + } + if exists { + continue + } + sql, err := migrations.ReadFile("migrations/" + name) + if err != nil { + return err + } + tx, err := p.pool.Begin(ctx) + if err != nil { + return err + } + if _, err = tx.Exec(ctx, string(sql)); err == nil { + _, err = tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name) + } + if err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply migration %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return err + } + } + return nil +} + +func (p *Postgres) ListRepositories(ctx context.Context) ([]Repository, error) { + rows, err := p.pool.Query(ctx, `SELECT id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at FROM repositories ORDER BY full_name`) + if err != nil { + return nil, err + } + defer rows.Close() + repositories := make([]Repository, 0) + for rows.Next() { + var repository Repository + if err := rows.Scan(&repository.ID, &repository.GiteaID, &repository.Owner, &repository.Name, &repository.FullName, &repository.CloneURL, &repository.DefaultBranch, &repository.CreatedAt, &repository.UpdatedAt); err != nil { + return nil, err + } + repositories = append(repositories, repository) + } + return repositories, rows.Err() +} + +func (p *Postgres) UpsertRepository(ctx context.Context, repository gitea.Repository) (Repository, error) { + id, err := ids.Repository() + if err != nil { + return Repository{}, err + } + return upsertRepository(ctx, p.pool, id, repository) +} + +type querier interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func upsertRepository(ctx context.Context, q querier, id string, repository gitea.Repository) (Repository, error) { + owner := repository.OwnerName() + fullName := repository.FullName + if fullName == "" { + fullName = owner + "/" + repository.Name + } + var saved Repository + err := q.QueryRow(ctx, ` + INSERT INTO repositories (id, gitea_id, owner, name, full_name, clone_url, default_branch) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (gitea_id) DO UPDATE SET + owner=EXCLUDED.owner, name=EXCLUDED.name, full_name=EXCLUDED.full_name, + clone_url=EXCLUDED.clone_url, default_branch=EXCLUDED.default_branch, updated_at=now() + RETURNING id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at`, + id, repository.ID, owner, repository.Name, fullName, repository.CloneURL, repository.DefaultBranch, + ).Scan(&saved.ID, &saved.GiteaID, &saved.Owner, &saved.Name, &saved.FullName, &saved.CloneURL, &saved.DefaultBranch, &saved.CreatedAt, &saved.UpdatedAt) + return saved, err +} + +func (p *Postgres) RecordWebhook(ctx context.Context, delivery WebhookDelivery) (bool, error) { + tx, err := p.pool.Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(ctx) }() + + result, err := tx.Exec(ctx, `INSERT INTO webhook_deliveries (delivery_id, event, event_type, payload_sha256) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, delivery.DeliveryID, delivery.Event, delivery.EventType, delivery.PayloadSHA256) + if err != nil { + return false, err + } + if result.RowsAffected() == 0 { + var existingEvent, existingEventType, existingDigest string + if err := tx.QueryRow(ctx, `SELECT event, event_type, payload_sha256 FROM webhook_deliveries WHERE delivery_id=$1`, delivery.DeliveryID).Scan(&existingEvent, &existingEventType, &existingDigest); err != nil { + return false, err + } + if existingEvent != delivery.Event || existingEventType != delivery.EventType || existingDigest != delivery.PayloadSHA256 { + return false, fmt.Errorf("webhook delivery ID %q was reused with different content", delivery.DeliveryID) + } + return true, nil + } + if delivery.Repository != nil && delivery.Repository.ID > 0 { + id, err := ids.Repository() + if err != nil { + return false, err + } + if _, err := upsertRepository(ctx, tx, id, *delivery.Repository); err != nil { + return false, err + } + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return false, nil +} + +var _ Store = (*Postgres)(nil)