diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
new file mode 100644
index 0000000..dca4df5
--- /dev/null
+++ b/.gitea/workflows/ci.yaml
@@ -0,0 +1,54 @@
+name: CI
+
+on:
+ push:
+ branches: [automc]
+ tags: ["automc-v*"]
+ pull_request:
+ branches: [automc]
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Build
+ run: go build ./...
+ - name: Vet
+ run: go vet ./...
+ - name: Test automc package
+ run: go test ./internal/automc/...
+ - name: Test full suite
+ run: go test ./...
+
+ docker:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ needs: validate
+ if: github.event_name == 'push'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Compute tags
+ id: meta
+ run: |
+ if [[ "$GITEA_REF" == refs/tags/automc-v* ]]; then
+ echo "tag=${GITEA_REF#refs/tags/}" >> "$GITEA_OUTPUT"
+ else
+ echo "tag=automc" >> "$GITEA_OUTPUT"
+ fi
+ echo "sha=sha-$(echo "$GITEA_SHA" | cut -c1-7)" >> "$GITEA_OUTPUT"
+
+ - name: Login to registry
+ run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.timemachine.center -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
+
+ - name: Build and push
+ run: |
+ IMG=git.timemachine.center/timemachine/${{ gitea.event.repository.name }}
+ docker build -t "$IMG:${{ steps.meta.outputs.tag }}" -t "$IMG:${{ steps.meta.outputs.sha }}" .
+ docker push "$IMG:${{ steps.meta.outputs.tag }}"
+ docker push "$IMG:${{ steps.meta.outputs.sha }}"
diff --git a/FORK.md b/FORK.md
new file mode 100644
index 0000000..c143091
--- /dev/null
+++ b/FORK.md
@@ -0,0 +1,51 @@
+# Fork status — Timemachine/mc-router
+
+This is a **soft fork** of [`itzg/mc-router`](https://github.com/itzg/mc-router) maintained for the [automc](https://git.timemachine.center/Timemachine/automc) Minecraft management platform.
+
+## Branch model
+
+| Branch | Tracks | Contents |
+|---|---|---|
+| `main` | `upstream/main` (github.com/itzg/mc-router) | Verbatim mirror. **Do not commit here.** |
+| `automc` | branched from `v1.42.1` | Soft fork; carries the automc-specific patch. |
+
+## Patch surface
+
+Minimal — designed for low-friction rebases.
+
+| Path | Type | Why |
+|---|---|---|
+| `cmd/mc-router/main.go` | edit | 1 import line + 4-line `automc.Wire(ctx)` call. Only upstream file modified. |
+| `internal/automc/` | new dir | Self-contained extension package — no upstream conflicts possible. |
+| `docs/AUTOMC.md` | new | Operator doc for the extensions. See [`docs/AUTOMC.md`](docs/AUTOMC.md). |
+| `Makefile` | edit | Appended `sync-upstream` + `automc-build` targets. |
+| `go.mod` / `go.sum` | edit | Added `github.com/jackc/pgx/v5 v5.8.0` for LISTEN/NOTIFY. |
+| `FORK.md` | new | This file. |
+
+Everything else stays untouched. No edits to `server/`, `mcproto/`, `cmd/mc-router/` beyond the wire-call.
+
+## What the fork adds
+
+1. **Postgres LISTEN/NOTIFY route source** — drop-in alternative to `--routes-config`, `--api-binding`, K8s, and Docker route sources. Pulled in via `AUTOMC_DSN` env var.
+2. **HTTP waker integration** — registers a `WakerFunc` per route that POSTs to a control-plane (`AUTOMC_WAKER_URL`) and polls until `state=running`.
+
+Both are opt-in. With `AUTOMC_DSN` unset, the binary behaves exactly like upstream `itzg/mc-router`.
+
+See [`docs/AUTOMC.md`](docs/AUTOMC.md) for env vars, schema, troubleshooting.
+
+## Sync from upstream
+
+```
+make sync-upstream
+```
+
+Rebases `automc` onto `upstream/main`, runs `go build ./...` and `go test ./internal/automc/...`. If `server.Routes.CreateMapping` signature changes, only [`internal/automc/pgsync.go`](internal/automc/pgsync.go) needs adjustment.
+
+## Reporting issues
+
+| Where it goes | What |
+|---|---|
+| [`Timemachine/mc-router`](https://git.timemachine.center/Timemachine/mc-router/issues) | automc-specific bugs (pg sync, waker, fork mechanics) |
+| [`itzg/mc-router`](https://github.com/itzg/mc-router/issues) | Upstream core bugs (routing, mcproto, REST API) |
+
+If unsure, file upstream first — most reports turn out to be upstream-side.
diff --git a/docs/AUTOMC.md b/docs/AUTOMC.md
index 87f71f1..d228bff 100644
--- a/docs/AUTOMC.md
+++ b/docs/AUTOMC.md
@@ -38,7 +38,7 @@ $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS automc_servers_route_notify ON servers;
CREATE TRIGGER automc_servers_route_notify
- AFTER INSERT OR UPDATE OF domain, address, state OR DELETE ON servers
+ AFTER INSERT OR UPDATE OF domain, address, state, enabled OR DELETE ON servers
FOR EACH ROW EXECUTE FUNCTION automc_notify_routes_changed();
```
@@ -46,6 +46,47 @@ The trigger fires on every mutation to a route-relevant column. mc-router holds
State column is not read by mc-router; it exists to drive the trigger and for the waker's own ready-check.
+### Sync lifecycle
+
+```mermaid
+sequenceDiagram
+ participant Wire as automc.Wire
+ participant Sync as syncer
+ participant PG as Postgres
+ participant R as server.Routes
+
+ Wire->>Sync: newSyncer(dsn, waker)
+ Wire-)Sync: go run(ctx)
+ Note over Sync: backoff = 1s
+
+ loop reconnect (until ctx cancelled)
+ Sync->>PG: pgx.Connect(dsn)
+ alt connect ok
+ Sync->>PG: LISTEN automc_routes_changed
+ Sync->>PG: SELECT name, domain, address FROM servers
+ PG-->>Sync: initial rows
+ Sync->>Sync: diff vs current (empty on first run)
+ loop for each add
+ Sync->>R: CreateMapping(host, addr, waker)
+ end
+ loop for each del
+ Sync->>R: DeleteMapping(host)
+ end
+ Note over Sync: backoff reset
+
+ loop while connection healthy
+ Sync->>PG: WaitForNotification(ctx)
+ PG-->>Sync: NOTIFY automc_routes_changed
+ Sync->>PG: SELECT all servers
+ PG-->>Sync: fresh rows
+ Sync->>R: CreateMapping / DeleteMapping (diff only)
+ end
+ else connect or notify error
+ Note over Sync: warn log
sleep backoff
backoff = min(backoff*2, 30s)
+ end
+ end
+```
+
## Waker contract
When `AUTOMC_WAKER_URL` is set, every route is registered with a `WakerFunc` that the upstream connector calls only when a client tries to LOGIN (not on status pings — those are answered locally via `--use-asleep-motd`).
@@ -58,6 +99,47 @@ The waker:
The polled address overrides the route's static address for that connection only — useful when the backend's IP is allocated lazily.
+### Waker dispatch on stopped backend
+
+```mermaid
+sequenceDiagram
+ actor Client as MC Client
+ participant MR as mc-router
+ participant SM as server-manager (waker URL)
+ participant MC as MC backend
+
+ Client->>MR: TCP + Handshake (next_state=login)
+ Note over MR: Routes.FindBackendForServerAddress
returns backend + WakerFunc
+ MR->>MC: dial backend address
+ MC--xMR: connection refused
+ Note over MR: WakerFunc != nil →
invoke it before kicking client
+
+ MR->>SM: POST /servers/test1/start
X-API-Key: ...
+ alt 202 Accepted
+ SM-->>MR: 202
+ else 409 Conflict (already starting)
+ SM-->>MR: 409 — treat as success
+ else 5xx
+ SM-->>MR: error → WakerFunc returns err
+ MR->>Client: kick (connection refused)
+ end
+
+ loop every 2s, up to 90s
+ MR->>SM: GET /servers/test1
+ alt running
+ SM-->>MR: 200 state=running address=10.0.0.5:25565
+ else still starting
+ SM-->>MR: 200 state=starting
+ Note over MR: continue polling
+ end
+ end
+
+ Note over MR: WakerFunc returns address
+ MR->>MC: dial polled address
+ MC-->>MR: ok
+ MR->>Client: splice handshake-and-onward
+```
+
## Upstream sync
```
@@ -76,6 +158,43 @@ go.mod / go.sum — pgx dep added; mergeable
If upstream renames `server.Routes.CreateMapping` or changes its signature, only `pgsync.go:apply` needs adjustment.
+## Quick start
+
+```bash
+# 1. Start a postgres reachable from mc-router
+podman run -d --name automc-pg \
+ -e POSTGRES_PASSWORD=test -e POSTGRES_DB=automc \
+ -p 127.0.0.1:5432:5432 docker.io/postgres:16-alpine
+
+# 2. Apply the schema + trigger (see "Postgres schema" above)
+podman exec -i automc-pg psql -U postgres -d automc < schema.sql
+
+# 3. Run mc-router with automc enabled
+AUTOMC_DSN="postgres://postgres:test@127.0.0.1:5432/automc?sslmode=disable" \
+AUTOMC_WAKER_URL="http://server-manager:8080" \
+AUTOMC_WAKER_TOKEN="$SM_API_KEY" \
+mc-router --port 25565 --api-binding 127.0.0.1:25590 --use-asleep-motd
+
+# 4. Add a route
+podman exec automc-pg psql -U postgres -d automc -c \
+ "INSERT INTO servers (name, domain, address, state) \
+ VALUES ('test1', 'test1.example.com', '127.0.0.1:25001', 'running');"
+
+# Log should show: automc route +: test1.example.com → 127.0.0.1:25001 (test1)
+# REST API should show it: curl http://127.0.0.1:25590/routes
+```
+
+## Troubleshooting
+
+| Symptom | Likely cause | Check |
+|---|---|---|
+| Binary starts but no `automc: pg route sync started` log | `AUTOMC_DSN` empty or unset | `env \| grep AUTOMC_DSN` |
+| `automc pgsync disconnected; reconnecting in 1s` repeating | pg unreachable / wrong DSN | Test with `psql "$AUTOMC_DSN" -c "SELECT 1"` |
+| INSERT into `servers` doesn't fire a route | Trigger missing or not on the right columns | `\d+ servers` in psql — confirm `automc_servers_route_notify` trigger exists |
+| Routes appear in REST `/routes` but client connect times out | Backend address wrong / unreachable from mc-router | `podman exec mc-router nc -zv