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
30 changes: 19 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@ It provides:

- Git over SSH and HTTP (clone / fetch / push).
- Git LFS (object transfer over HTTP; disk or S3 storage).
- A control API to manage repositories, users, SSH keys, tokens, and permissions.
- A control API to manage repositories, users, SSH keys, tokens, permissions, and webhooks.
- A `read` / `write` / `admin` permission model enforced before every Git operation.
- Push webhooks, signed and delivered off the push path.

Out of scope unless explicitly requested: issues, pull requests, wikis, CI/actions,
stars, social profiles, a repo-browsing UI, package registries.

Push webhooks are specified but **not yet implemented** — see [Planned: webhooks](#planned-webhooks).

## Commands

```sh
Expand Down Expand Up @@ -142,11 +141,20 @@ widely-used packages. Acceptable areas: SSH server library, HTTP router/middlewa
(`chi`), SQLite driver/query tooling, structured logging, config loading, object
storage clients. Do not roll your own SSH or Git protocol implementation.

## Planned: webhooks

Not yet implemented. When built:

- Emit a push webhook only **after** a successful push, off the push path (an
in-process background queue with bounded retries — no external job system).
- Payload includes at least: `event`, `repo_id`, `ref`, `old_sha`, `new_sha`, `pusher_id`.
- Sign each delivery with a per-webhook secret (constant-time comparison).
## Webhooks

- Emitted only **after** a successful push, off the push path: receive-pack runs,
`gitbackend` diffs the repo's refs before/after, and the transport hands the
changes to the webhooks service. Delivery is an in-process bounded queue with
worker goroutines and retries — no external job system.
- One delivery per changed ref. Payload is self-describing: `event`, `ref`,
`before`/`after`, `created`/`deleted`, a `repository` object (`id`, `name`,
`full_name`), a `pusher` object (`id`, `username`), and `timestamp`. Creates/
deletes use the all-zero SHA for the missing side.
- Each delivery is signed with the per-webhook secret: `X-HeadlessGit-Signature:
sha256=<hmac>` over the raw body. The secret is generated server-side and
returned once at registration; it is stored recoverably (needed to sign), unlike
hashed tokens.
- Registered per repo via the control API; webhook detection lives in `gitbackend`
(refs), the service in `internal/services/webhooks`, dispatch at the receive-pack
call sites.
90 changes: 69 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Basically, this is a Git layer of infrastructure you'd put _underneath_ a projec
- **Git LFS** for large files, with object storage on local disk or any S3-compatible bucket (AWS S3, Cloudflare R2, MinIO).
- A small **control API**, RESTful api to manage repositories, users, SSH keys, tokens, and permissions.
- Simple **permission model** (`read` / `write` / `admin`) enforced before every Git operation.
- **Push webhooks** — signed deliveries on every successful push.
- Bare-repository storage on a filesystem, with SQLite for metadata.

## Example
Expand Down Expand Up @@ -106,35 +107,82 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve

**Accounts & credentials**

| Method | Path | Body | Description |
| -------- | --------------------------------- | -------------------- | ------------------------------------------------------------ |
| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`). |
| `GET` | `/users/{id}` | — | Get an account. |
| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. |
| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. |
| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. |
| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. |
| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. |
| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). |
| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. |
| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. |
| Method | Path | Body | Description |
| -------- | ------------------------------ | -------------------- | ------------------------------------------------------------ |
| `POST` | `/users` | `{username, kind}` | Create a user/service account (`kind`: `user` \| `service`). |
| `GET` | `/users/{id}` | — | Get an account. |
| `GET` | `/users/{id}/repositories` | — | List repositories owned by the account. |
| `POST` | `/users/{id}/ssh-keys` | `{title, publicKey}` | Register an SSH public key. |
| `GET` | `/users/{id}/ssh-keys` | — | List the account's SSH keys. |
| `DELETE` | `/users/{id}/ssh-keys/{keyId}` | — | Revoke an SSH key. |
| `POST` | `/users/{id}/tokens` | `{title}` | Mint a token; the raw value is returned **once**. |
| `GET` | `/users/{id}/tokens` | — | List the account's tokens (never the secret). |
| `DELETE` | `/users/{id}/tokens/{tokenId}` | — | Revoke a single token. |
| `DELETE` | `/users/{id}/tokens` | — | Revoke **all** of the account's tokens. |

**Repositories & permissions**

| Method | Path | Body | Description |
| -------- | ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). |
| `GET` | `/repositories/{id}` | — | Get repository metadata. |
| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). |
| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). |
| `GET` | `/repositories/{id}/permissions` | — | List collaborators. |
| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). |
| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. |
| Method | Path | Body | Description |
| -------- | ----------------------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). |
| `GET` | `/repositories/{id}` | — | Get repository metadata. |
| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). |
| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). |
| `GET` | `/repositories/{id}/permissions` | — | List collaborators. |
| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). |
| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. |
| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. |
| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. |

### Health

The control port also serves an unauthenticated `GET /healthz` readiness probe. It returns `200 {"status":"ok"}` when the database is reachable and `503 {"status":"unavailable"}` otherwise, and backs the container `HEALTHCHECK`.

## Webhooks

Register a webhook on a repository and `headlessgit` will `POST` to it after every successful push.

One delivery is sent **per changed ref** (a branch/tag create, update, or delete — not per file or commit). The JSON body:

```json
{
"event": "push",
"ref": "refs/heads/main",
"before": "0000000000000000000000000000000000000000",
"after": "344018f5c8bce597cfb1b13058edc688f3a13230",
"created": true,
"deleted": false,
"repository": {
"id": 6,
"name": "HeadlessGit",
"full_name": "Axenos-dev/HeadlessGit"
},
"pusher": { "id": 7, "username": "Axenos-dev" },
"timestamp": "2026-06-29T19:06:48Z"
}
```

`before`/`after` are the ref's SHAs around the push; a create has `before` all-zero (`created: true`), a delete has `after` all-zero (`deleted: true`). `repository.full_name` is `namespace/name`.

Each request carries these headers:

| Header | Value |
| ------------------------- | ----------------------------------------------------------------------------- |
| `X-HeadlessGit-Event` | `push` |
| `X-HeadlessGit-Delivery` | Unique id for this delivery attempt. |
| `X-HeadlessGit-Signature` | `sha256=<hex>` — HMAC-SHA256 of the **raw body** keyed by the webhook secret. |

Verify a delivery by recomputing the HMAC over the exact request body with the secret returned at registration, e.g.:

```go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(expected), []byte(r.Header.Get("X-HeadlessGit-Signature")))
```

The secret is generated server-side and shown **once** in the registration response.

## Development

```sh
Expand Down
9 changes: 8 additions & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/Axenos-dev/HeadlessGit/internal/services/permissions"
"github.com/Axenos-dev/HeadlessGit/internal/services/repositories"
"github.com/Axenos-dev/HeadlessGit/internal/services/users"
"github.com/Axenos-dev/HeadlessGit/internal/services/webhooks"
"github.com/Axenos-dev/HeadlessGit/internal/storage"
"go.uber.org/zap"
)
Expand Down Expand Up @@ -92,6 +93,11 @@ func main() {
)
}

webhooksService := webhooks.NewService(
root.With(zap.String("service", "webhooks")),
webhooks.NewRegistry(db),
)

ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
Expand All @@ -104,6 +110,7 @@ func main() {
Users: usersService,
Authentication: authService,
Authorization: permsService,
Webhooks: webhooksService,
GitBackend: gitBackend,
LFS: lfsService,
DB: db,
Expand All @@ -113,7 +120,7 @@ func main() {
}
}

func newLFSStorage(cfg config.LFSConfig) (lfs.ObjectStorage, error) {
func newLFSStorage(cfg config.LFSConfig) (storage.Storage, error) {
switch cfg.StorageType {
case "disk":
return storage.NewDisk(cfg.Root)
Expand Down
9 changes: 9 additions & 0 deletions internal/db/gen/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 88 additions & 0 deletions internal/db/gen/webhooks.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions internal/db/migrations/0004_webhooks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- +goose Up
create table if not exists webhooks (
id integer primary key,
repository_id integer not null,

secret text not null,
url text not null,

created_at_unix_ms integer not null default (
CAST(unixepoch('subsec') * 1000 as integer)
),
updated_at_unix_ms integer,

foreign key (repository_id) references repositories(id) on delete cascade
);

create index if not exists idx_webhooks_repository_id on webhooks(repository_id);

-- +goose Down
drop index if exists idx_webhooks_repository_id;

drop table if exists webhooks;
14 changes: 14 additions & 0 deletions internal/db/queries/webhooks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- name: CreateWebhook :one
insert into webhooks (
repository_id, secret, url
) values (
?, ?, ?
) returning *;

-- name: ListWebhooksForRepository :many
select * from webhooks
where repository_id=?;

-- name: DeleteWebhook :exec
delete from webhooks
where id=? and repository_id=?;
29 changes: 29 additions & 0 deletions internal/domain/webhooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package domain

import "time"

type Webhook struct {
ID int64
RepositoryID int64
URL string
Secret string
CreatedAt time.Time
UpdatedAt time.Time
}

type RepositoryEvent struct {
Event string

RepositoryID int64
RepositoryName string
RepositoryFullName string // namespace/name

PusherID int64
PusherUsername string

Ref string
OldSHA string
NewSHA string

Timestamp time.Time
}
15 changes: 15 additions & 0 deletions internal/gitbackend/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package gitbackend

import (
"context"
"io"
)

// git backend is a polymorphic thing,
// it can be either local (we store bare repos on a disk),
// or the repos itself we store on storage nodes (coming soon)
type Backend interface {
AdvertiseRefs(ctx context.Context, storagePath string, svc Service, stdout io.Writer) error
UploadPack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) error
ReceivePack(ctx context.Context, storagePath string, stateless bool, stdin io.Reader, stdout, stderr io.Writer) ([]RefChange, error)
}
Loading
Loading