Files
mc-router/docs/AUTOMC.md
T
claude-timemachine 7884fb1c5f
CI / validate (push) Successful in 47s
CI / docker (push) Successful in 44s
automc: schema-fit query + CI + FORK doc
- pgsync.go: filter rows where enabled IS NOT FALSE and domain IS NOT NULL,
  matching the existing automc servers table (domain nullable, enabled
  defaults true). Trigger doc now includes UPDATE OF enabled.
- .gitea/workflows/ci.yaml: build/test on push to automc branch + tags,
  publish container as git.timemachine.center/timemachine/mc-router:automc.
- FORK.md: soft-fork relationship doc at repo root.
- docs/AUTOMC.md: quick-start recipe, troubleshooting table,
  sync-lifecycle and waker-dispatch sequence diagrams.
2026-05-27 22:57:51 +02:00

8.1 KiB

automc extensions

Soft fork of itzg/mc-router that adds Postgres-driven route management and an HTTP waker, without touching upstream behavior by default.

The internal/automc package is opt-in via env vars: with AUTOMC_DSN unset, the binary behaves exactly like upstream.

Environment variables

Var Required Purpose
AUTOMC_DSN yes (to enable) Postgres DSN, e.g. postgres://user:pass@host:5432/automc?sslmode=disable. When unset, automc is a no-op.
AUTOMC_WAKER_URL no Base URL of the waker control plane (server-manager). When set, stopped backends are auto-started on login.
AUTOMC_WAKER_TOKEN no Sent as X-API-Key header on every waker request.

Recommended companion upstream flags:

  • --use-asleep-motd / --use-loading-motd — supplies friendly MOTD to clients during the wake window. Already implemented upstream; automc does not duplicate this.

Postgres schema

Apply this once to the database referenced by AUTOMC_DSN. mc-router only reads; the trigger is what tells it to re-read.

CREATE TABLE IF NOT EXISTS servers (
    name    TEXT PRIMARY KEY,
    domain  TEXT NOT NULL,
    address TEXT NOT NULL,
    state   TEXT NOT NULL DEFAULT 'stopped',
    UNIQUE(domain)
);

CREATE OR REPLACE FUNCTION automc_notify_routes_changed() RETURNS trigger AS $$
BEGIN
    PERFORM pg_notify('automc_routes_changed', '');
    RETURN NULL;
END;
$$ 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, enabled OR DELETE ON servers
    FOR EACH ROW EXECUTE FUNCTION automc_notify_routes_changed();

The trigger fires on every mutation to a route-relevant column. mc-router holds a persistent LISTEN automc_routes_changed and re-runs SELECT name, domain, address FROM servers WHERE domain != '' AND address != '', diffing against its in-memory map. Adds/removes/changes call server.Routes.CreateMapping and DeleteMapping directly — no file I/O.

State column is not read by mc-router; it exists to drive the trigger and for the waker's own ready-check.

Sync lifecycle

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<br/>sleep backoff<br/>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).

The waker:

  1. POST {AUTOMC_WAKER_URL}/servers/{name}/start — fire-and-forget start signal. 409 Conflict is treated as success (already starting).
  2. Polls GET {AUTOMC_WAKER_URL}/servers/{name} every 2 s, expecting JSON {"state":"running","address":"host:port"}.
  3. Returns the polled address once state == "running", or errors after 90 s.

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

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<br/>returns backend + WakerFunc
    MR->>MC: dial backend address
    MC--xMR: connection refused
    Note over MR: WakerFunc != nil →<br/>invoke it before kicking client

    MR->>SM: POST /servers/test1/start<br/>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

make sync-upstream

Fetches upstream/main, rebases the automc branch onto it, builds, and runs the automc tests. The patch surface is intentionally tiny so rebase conflicts are rare:

cmd/mc-router/main.go  — 1 import line + 4-line Wire call
internal/automc/       — new directory; no upstream conflicts possible
docs/AUTOMC.md         — new doc; no upstream conflicts
Makefile               — appended targets only
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

# 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 <address>
WakerFunc never called for stopped backends AUTOMC_WAKER_URL empty Set it; without it stopped backends get connection refused on login
waker timeout for X after 1m30s Backend takes longer than 90 s to come up Tune wakerPollTimeout (currently a const in waker.go:18); planned env var

Verification

go build ./...
go test ./internal/automc/...
go vet ./...

End-to-end smoke recipe verified 2026-05-27 on local podman: INSERT/UPDATE/DELETE in servers table propagated to /routes REST API within ~1 s; postgres restart triggered exponential backoff reconnect (1 s → 30 s cap) and full route re-sync on reconnect.